mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Updated default dashboard layout to add various vulnerability/threat intelligence feeds
This commit is contained in:
1 parent
8dab71c673
commit
ab06f4dbdb
36 files changed
+2969
-56
No files matched your search
@@ -0,0 +1,163 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/feeds"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// Bringing the threat-intel feed aggregator up.
|
||||
//
|
||||
// Like the webhook feature, it is independent of automation and built in New rather than in
|
||||
// SetAutomation — an operator wanting CVE and KEV intel on their dashboard should not have to
|
||||
// arm an agent. The only switches are --no-feeds (removes it) and the off-by-default Settings
|
||||
// toggle (fetches nothing until armed). internal/feeds imports nothing from this package; the
|
||||
// outbound client is injected here as feedsFetch, the one place internal/proxy is reached.
|
||||
|
||||
// initFeeds opens feeds.json and builds the aggregator over the curated source set.
|
||||
//
|
||||
// A failure is non-fatal and disables the feature rather than the proxy, the same way an
|
||||
// unreadable webhooks.json does, and is logged loudly so a silently absent store does not look
|
||||
// like the operator's settings vanished.
|
||||
func (s *APIServer) initFeeds() {
|
||||
if s.listenerMode || s.cfg.NoFeeds {
|
||||
return
|
||||
}
|
||||
store, err := feeds.NewSettingsStore(s.cfg.DataDir)
|
||||
if err != nil {
|
||||
log.Printf("[feeds] threat-intel feeds are disabled this run: %v", err)
|
||||
return
|
||||
}
|
||||
s.feedsStore = store
|
||||
s.feeds = feeds.NewAggregator(feeds.DefaultSources(), s.feedsFetch)
|
||||
}
|
||||
|
||||
// feedsFetch is the injected FetchFunc: a verified-TLS (insecure=false), no-redirect GET with
|
||||
// a request timeout and a bounded read. NewOutboundHTTPClient rather than NewHTTPClient because
|
||||
// NVD/CISA/ExploitDB/FIRST.org are public infrastructure to be authenticated, not MITM targets.
|
||||
//
|
||||
// Direct egress by default: feed traffic is incidental intel, not engagement traffic, so it
|
||||
// should not consume or reveal the operator's SOCKS tunnel. Passing a nil TransportConfig
|
||||
// leaves DialContext unset (a direct dial); only when the operator opts in via RouteThroughSocks
|
||||
// is s.transport passed, which honors SOCKS when one is configured. Built per fetch and closed,
|
||||
// because the transport snapshots the dialer at build time and has no idle timeout.
|
||||
func (s *APIServer) feedsFetch(ctx context.Context, rawURL string, limitBytes int64) ([]byte, error) {
|
||||
var tc *proxy.TransportConfig
|
||||
if s.feedsStore != nil && s.feedsStore.Get().RouteThroughSocks {
|
||||
tc = s.transport
|
||||
}
|
||||
client := proxy.NewOutboundHTTPClient(tc, false)
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, application/atom+xml, application/rss+xml, application/xml;q=0.9, */*;q=0.1")
|
||||
req.Header.Set("User-Agent", "Joro/"+s.buildInfo.Version)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("feed endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, limitBytes))
|
||||
}
|
||||
|
||||
// startFeedsLoop runs the periodic refresher for the life of the server.
|
||||
//
|
||||
// A one-minute ticker is the backstop resolution; the actual cadence is the operator's
|
||||
// interval (floored at 15 min), tracked by nextRun. The loop no-ops while feeds are disabled
|
||||
// in settings, so toggling them on takes effect with no restart — and a handler kicks an
|
||||
// immediate refresh on an enable-flip so the UI does not wait up to a minute. feedsCtx is set
|
||||
// here, before serving, so a handler-kicked refresh can outlive its request.
|
||||
func (s *APIServer) startFeedsLoop(ctx context.Context) {
|
||||
if s.feeds == nil || s.feedsStore == nil {
|
||||
return
|
||||
}
|
||||
s.feedsCtx = ctx
|
||||
go func() {
|
||||
// A short settle so startup is not a thundering herd of outbound calls.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
var nextRun time.Time // zero => run on the first enabled pass
|
||||
for {
|
||||
st := s.feedsStore.Get()
|
||||
if st.Enabled && (nextRun.IsZero() || !time.Now().Before(nextRun)) {
|
||||
s.refreshFeedsTick(ctx)
|
||||
nextRun = time.Now().Add(time.Duration(st.IntervalMinutes) * time.Minute)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// refreshFeedsTick refreshes the enabled categories and broadcasts the summary. A no-op when
|
||||
// feeds are off, so every caller (the loop, the manual-refresh handler, the enable kick) can
|
||||
// call it unconditionally.
|
||||
func (s *APIServer) refreshFeedsTick(ctx context.Context) {
|
||||
if s.feeds == nil || s.feedsStore == nil {
|
||||
return
|
||||
}
|
||||
st := s.feedsStore.Get()
|
||||
if !st.Enabled {
|
||||
return
|
||||
}
|
||||
snap := s.feeds.Refresh(ctx, st.Categories)
|
||||
s.hub.Broadcast() <- event.WSEvent{Type: "feeds.updated", Data: feedsSummary(snap)}
|
||||
}
|
||||
|
||||
// feedsContext returns the server-lifetime context for a handler-kicked refresh, falling back
|
||||
// to Background if the loop never started (it always does in proxy mode when feeds are on).
|
||||
func (s *APIServer) feedsContext() context.Context {
|
||||
if s.feedsCtx != nil {
|
||||
return s.feedsCtx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// feedsSummaryData is the small, droppable hint pushed on feeds.updated. The client re-GETs
|
||||
// the authoritative snapshot, the same pattern tech.detected / detect.summary use.
|
||||
type feedsSummaryData struct {
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
AnyStale bool `json:"anyStale"`
|
||||
}
|
||||
|
||||
func feedsSummary(snap feeds.Snapshot) feedsSummaryData {
|
||||
counts := make(map[string]int, len(snap.Items))
|
||||
for cat, list := range snap.Items {
|
||||
counts[string(cat)] = len(list)
|
||||
}
|
||||
anyStale := false
|
||||
for _, src := range snap.Sources {
|
||||
if src.Stale {
|
||||
anyStale = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return feedsSummaryData{GeneratedAt: snap.GeneratedAt, Counts: counts, AnyStale: anyStale}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/feeds"
|
||||
)
|
||||
|
||||
// The threat-intel feed control plane. UI-only, proxy-mode only.
|
||||
//
|
||||
// Routes are registered unconditionally and gated here on requireFeeds, for the same reason
|
||||
// the webhook and automation planes are: an unregistered route falls through to the SPA
|
||||
// catch-all and answers 200 with HTML rather than 404 with JSON. There are no secrets to
|
||||
// redact — a feed snapshot is public intelligence — so unlike webhooks every field goes out
|
||||
// as stored.
|
||||
|
||||
// requireFeeds reports whether the aggregator is available, writing the JSON 404 if not. The
|
||||
// message names which off-state it is: --no-feeds (a posture) versus an unreadable feeds.json
|
||||
// (a fault).
|
||||
func (s *APIServer) requireFeeds(w http.ResponseWriter) *feeds.Aggregator {
|
||||
if s.cfg.NoFeeds {
|
||||
writeError(w, http.StatusNotFound,
|
||||
"threat-intel feeds are disabled on this instance; it was started with --no-feeds")
|
||||
return nil
|
||||
}
|
||||
if s.feeds == nil || s.feedsStore == nil {
|
||||
writeError(w, http.StatusNotFound,
|
||||
"threat-intel feeds are unavailable on this instance; see the startup log for why "+
|
||||
"~/.joro/feeds.json could not be read")
|
||||
return nil
|
||||
}
|
||||
return s.feeds
|
||||
}
|
||||
|
||||
// feedsResponse is the whole feed view: the operator's settings plus the current snapshot.
|
||||
type feedsResponse struct {
|
||||
Settings feeds.State `json:"settings"`
|
||||
Snapshot feeds.Snapshot `json:"snapshot"`
|
||||
}
|
||||
|
||||
// handleGetFeeds returns settings + snapshot. Served even while feeds are disabled (Enabled
|
||||
// false, empty items), so the UI can render its "enable" state from one call.
|
||||
func (s *APIServer) handleGetFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
agg := s.requireFeeds(w)
|
||||
if agg == nil {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedsResponse{
|
||||
Settings: s.feedsStore.Get(),
|
||||
Snapshot: agg.Snapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetFeedsSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.requireFeeds(w) == nil {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.feedsStore.Get())
|
||||
}
|
||||
|
||||
// handleSetFeedsSettings validates and persists settings (the store floors the interval), and
|
||||
// on an off->on flip kicks an immediate refresh so the dashboard fills without waiting for the
|
||||
// loop's next pass.
|
||||
func (s *APIServer) handleSetFeedsSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.requireFeeds(w) == nil {
|
||||
return
|
||||
}
|
||||
var in feeds.State
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
prev := s.feedsStore.Get()
|
||||
next, err := s.feedsStore.Set(in)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if next.Enabled && !prev.Enabled {
|
||||
go s.refreshFeedsTick(s.feedsContext())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, next)
|
||||
}
|
||||
|
||||
// handleRefreshFeeds triggers an out-of-band refresh, debounced server-side so a UI button
|
||||
// cannot push NVD past its rate limit regardless of the client.
|
||||
func (s *APIServer) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
agg := s.requireFeeds(w)
|
||||
if agg == nil {
|
||||
return
|
||||
}
|
||||
if !s.feedsStore.Get().Enabled {
|
||||
writeError(w, http.StatusConflict, "threat-intel feeds are turned off; enable them first")
|
||||
return
|
||||
}
|
||||
if !agg.TryBeginManual(60 * time.Second) {
|
||||
writeError(w, http.StatusTooManyRequests, "feeds were refreshed moments ago; try again shortly")
|
||||
return
|
||||
}
|
||||
go s.refreshFeedsTick(s.feedsContext())
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "refreshing"})
|
||||
}
|
||||
@@ -425,6 +425,13 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/webhooks/{id}/test", s.handleTestWebhook)
|
||||
mux.HandleFunc("GET /api/v1/webhooks/{id}/deliveries", s.handleListWebhookDeliveries)
|
||||
|
||||
// Threat-intel feeds. Registered unconditionally and gated on requireFeeds, for the same
|
||||
// SPA-catch-all reason as the block above. UI-only, proxy-mode only (nil in listener mode).
|
||||
mux.HandleFunc("GET /api/v1/feeds", s.handleGetFeeds)
|
||||
mux.HandleFunc("POST /api/v1/feeds/refresh", s.handleRefreshFeeds)
|
||||
mux.HandleFunc("GET /api/v1/feeds/settings", s.handleGetFeedsSettings)
|
||||
mux.HandleFunc("PUT /api/v1/feeds/settings", s.handleSetFeedsSettings)
|
||||
|
||||
// Plugin routes (dynamic, based on loaded plugins).
|
||||
registerPluginRoutes(s, mux)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/echo"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/feeds"
|
||||
"github.com/BishopFox/joro/internal/fuzzer"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
"github.com/BishopFox/joro/internal/jsautomation"
|
||||
@@ -214,6 +215,15 @@ type APIServer struct {
|
||||
webhookDeliver *webhook.Deliverer
|
||||
webhookDispatch *webhook.Dispatcher
|
||||
|
||||
// Threat-intelligence feeds. Independent of automation and of webhooks: a curated,
|
||||
// read-only aggregator (NVD / CISA KEV / advisory headlines) the dashboard renders.
|
||||
// Both nil under --no-feeds, in listener mode, or when feeds.json could not be read.
|
||||
// feedsCtx is the server-lifetime context so a refresh kicked from a handler outlives
|
||||
// the request; set once in startFeedsLoop before serving. See feeds_wire.go.
|
||||
feeds *feeds.Aggregator
|
||||
feedsStore *feeds.SettingsStore
|
||||
feedsCtx context.Context
|
||||
|
||||
buildInfo BuildInfo
|
||||
cancelFunc context.CancelFunc
|
||||
restart bool
|
||||
@@ -379,6 +389,7 @@ func New(
|
||||
// See initTriggers for why the trigger store moved out of newScriptManager.
|
||||
s.initTriggers()
|
||||
s.initWebhooks()
|
||||
s.initFeeds()
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -490,6 +501,9 @@ func (s *APIServer) Start(ctx context.Context) error {
|
||||
// No-ops entirely under --no-webhooks. Started after automation so the run
|
||||
// watcher startScriptTriggers registers is already in place.
|
||||
s.startWebhooks(ctx)
|
||||
// No-ops entirely under --no-feeds. Fetches nothing until the operator enables
|
||||
// feeds in Settings or on the Dashboard.
|
||||
s.startFeedsLoop(ctx)
|
||||
}
|
||||
|
||||
// Serve frontend (skip in listener mode - listener is API-only).
|
||||
|
||||
@@ -64,6 +64,12 @@ type Config struct {
|
||||
// engagement where nothing may leave the machine — a webhook is the one feature whose
|
||||
// bytes go somewhere Joro's scope does not describe.
|
||||
NoWebhooks bool
|
||||
// NoFeeds disables the threat-intelligence feed fetcher entirely: no routes
|
||||
// registered, no feeds.json read or written, no outbound request to NVD or CISA. A
|
||||
// deployment-posture switch in the same family as NoWebhooks — the answer for an
|
||||
// engagement where the operator's machine must make no incidental outbound calls.
|
||||
// The feature is off by default regardless; this removes it outright.
|
||||
NoFeeds bool
|
||||
// NoPlugins skips loading plugins from ~/.joro/plugins/ — the files are still
|
||||
// listed, so the UI can delete one, but no plugin code runs.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchFunc performs a bounded GET and returns the body. Injected by the api layer so this
|
||||
// package never imports internal/proxy; the implementation verifies TLS, follows no
|
||||
// redirects, honors the operator's SOCKS dialer, and reads at most limitBytes.
|
||||
type FetchFunc func(ctx context.Context, url string, limitBytes int64) ([]byte, error)
|
||||
|
||||
// perCategoryCap bounds how many items each category retains after a merge, so the rolling
|
||||
// cache (and the snapshot the UI holds) stays a fixed, small size.
|
||||
var perCategoryCap = map[Category]int{
|
||||
CategoryCVE: 60,
|
||||
CategoryKEV: 100,
|
||||
CategoryHeadline: 60,
|
||||
}
|
||||
|
||||
// Aggregator holds the rolling, thread-safe snapshot and refreshes it from its sources.
|
||||
//
|
||||
// Refresh merges new items into a per-category rolling set rather than replacing it, so a
|
||||
// 24-hour NVD window accumulates and a source that fails on one tick keeps its last-known
|
||||
// items (only its SourceStatus goes stale). That "never blank the cache" rule is why the UI
|
||||
// can show "stale since X" instead of an empty panel.
|
||||
type Aggregator struct {
|
||||
mu sync.RWMutex
|
||||
sources []Source
|
||||
fetch FetchFunc
|
||||
items map[Category][]Item
|
||||
status map[string]SourceStatus
|
||||
lastRun time.Time
|
||||
generated time.Time
|
||||
}
|
||||
|
||||
func NewAggregator(sources []Source, fetch FetchFunc) *Aggregator {
|
||||
a := &Aggregator{
|
||||
sources: sources,
|
||||
fetch: fetch,
|
||||
items: map[Category][]Item{},
|
||||
status: map[string]SourceStatus{},
|
||||
}
|
||||
for _, s := range sources {
|
||||
a.status[s.ID] = SourceStatus{ID: s.ID, Name: s.Name, Category: s.Category}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Refresh fetches every source whose category is wanted (nil means all), sequentially so
|
||||
// total egress stays a trickle, and returns the resulting snapshot. Safe to call
|
||||
// concurrently with Snapshot and the HTTP handlers.
|
||||
func (a *Aggregator) Refresh(ctx context.Context, want map[Category]bool) Snapshot {
|
||||
a.markRun()
|
||||
for _, src := range a.sources {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if want != nil && !want[src.Category] {
|
||||
continue
|
||||
}
|
||||
items, err := src.collect(ctx, a.fetch)
|
||||
a.applyResult(src, items, err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.generated = time.Now().UTC()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.recomputeKEVCrossLinks()
|
||||
// Enrich CVE + KEV items with EPSS scores (a clean join on the validated CVE id). Best
|
||||
// effort: a failure leaves the existing items unscored rather than failing the refresh.
|
||||
a.enrichEPSS(ctx)
|
||||
return a.Snapshot()
|
||||
}
|
||||
|
||||
// markRun stamps the start of a run so a manual refresh is debounced against it immediately,
|
||||
// before the (asynchronous) work begins.
|
||||
func (a *Aggregator) markRun() {
|
||||
a.mu.Lock()
|
||||
a.lastRun = time.Now().UTC()
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// TryBeginManual reports whether a manual refresh is allowed now, stamping the run clock when
|
||||
// it is so two rapid clicks cannot both pass. The periodic loop uses Refresh directly.
|
||||
func (a *Aggregator) TryBeginManual(min time.Duration) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !a.lastRun.IsZero() && time.Since(a.lastRun) < min {
|
||||
return false
|
||||
}
|
||||
a.lastRun = time.Now().UTC()
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *Aggregator) applyResult(src Source, items []Item, err error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
st := a.status[src.ID]
|
||||
st.LastAttempt = time.Now().UTC()
|
||||
if err != nil {
|
||||
// Keep-last-known: leave a.items[category] untouched and only mark the source stale.
|
||||
st.LastError = sanitizeText(err.Error(), maxError)
|
||||
st.Stale = true
|
||||
a.status[src.ID] = st
|
||||
return
|
||||
}
|
||||
// Stamp the trusted source name and category over anything the parser set, then merge.
|
||||
for i := range items {
|
||||
items[i].Source = src.Name
|
||||
items[i].Category = src.Category
|
||||
}
|
||||
merged := mergeItems(a.items[src.Category], items, perCategoryCap[src.Category])
|
||||
a.items[src.Category] = merged
|
||||
st.LastError = ""
|
||||
st.LastSuccess = st.LastAttempt
|
||||
st.Stale = false
|
||||
st.ItemCount = countBySource(merged, src.Name)
|
||||
a.status[src.ID] = st
|
||||
}
|
||||
|
||||
// recomputeKEVCrossLinks flags CVE-ticker items that also appear in the KEV catalog, joining
|
||||
// only on the validated CVE id (a scalar), never on any feed content.
|
||||
func (a *Aggregator) recomputeKEVCrossLinks() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
inKEV := make(map[string]bool, len(a.items[CategoryKEV]))
|
||||
for _, k := range a.items[CategoryKEV] {
|
||||
inKEV[k.ID] = true
|
||||
}
|
||||
for i := range a.items[CategoryCVE] {
|
||||
a.items[CategoryCVE][i].AlsoInKEV = inKEV[a.items[CategoryCVE][i].ID]
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns a deep copy of the current state. All three category keys are always
|
||||
// present (possibly empty arrays) so the client never indexes nil.
|
||||
func (a *Aggregator) Snapshot() Snapshot {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
items := make(map[Category][]Item, len(allCategories))
|
||||
for _, cat := range allCategories {
|
||||
list := a.items[cat]
|
||||
cp := make([]Item, len(list))
|
||||
copy(cp, list)
|
||||
items[cat] = cp
|
||||
}
|
||||
sources := make([]SourceStatus, 0, len(a.sources))
|
||||
for _, src := range a.sources {
|
||||
sources = append(sources, a.status[src.ID])
|
||||
}
|
||||
return Snapshot{Items: items, Sources: sources, GeneratedAt: a.generated}
|
||||
}
|
||||
|
||||
// mergeItems folds incoming items into existing ones, deduping by id (incoming wins),
|
||||
// sorting newest-first, and capping the result.
|
||||
func mergeItems(existing, incoming []Item, cap int) []Item {
|
||||
byID := make(map[string]Item, len(existing)+len(incoming))
|
||||
order := make([]string, 0, len(existing)+len(incoming))
|
||||
add := func(it Item) {
|
||||
if it.ID == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := byID[it.ID]; !ok {
|
||||
order = append(order, it.ID)
|
||||
}
|
||||
byID[it.ID] = it
|
||||
}
|
||||
for _, it := range existing {
|
||||
add(it)
|
||||
}
|
||||
for _, it := range incoming {
|
||||
add(it)
|
||||
}
|
||||
out := make([]Item, 0, len(order))
|
||||
for _, id := range order {
|
||||
out = append(out, byID[id])
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].Published.After(out[j].Published)
|
||||
})
|
||||
if cap > 0 && len(out) > cap {
|
||||
out = out[:cap]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countBySource(items []Item, name string) int {
|
||||
n := 0
|
||||
for _, it := range items {
|
||||
if it.Source == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EPSS (Exploit Prediction Scoring System, FIRST.org): the probability a CVE will be
|
||||
// exploited in the next 30 days. It joins cleanly onto the CVE and KEV sets by CVE id, so it
|
||||
// is an enrichment rather than a category — no panel of its own, just a score on existing
|
||||
// items. One API query per chunk per refresh; a failure leaves items unscored.
|
||||
//
|
||||
// The query is built from ids that already passed cleanCVEID, via url.Values, so nothing a
|
||||
// feed supplied can inject a parameter or path into the request.
|
||||
|
||||
const (
|
||||
epssBaseURL = "https://api.first.org/data/v1/epss"
|
||||
epssLimit = 2 << 20
|
||||
epssChunkSize = 100
|
||||
)
|
||||
|
||||
type epssResponse struct {
|
||||
Data []epssDatum `json:"data"`
|
||||
}
|
||||
|
||||
type epssDatum struct {
|
||||
CVE string `json:"cve"`
|
||||
EPSS string `json:"epss"` // a decimal string, e.g. "0.00123"
|
||||
}
|
||||
|
||||
// enrichEPSS fetches EPSS scores for the current CVE + KEV items and sets Item.EPSS.
|
||||
func (a *Aggregator) enrichEPSS(ctx context.Context) {
|
||||
ids := a.epssCandidates()
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
scores := make(map[string]float64, len(ids))
|
||||
for _, chunk := range chunkStrings(ids, epssChunkSize) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
m, err := a.fetchEPSS(ctx, chunk)
|
||||
if err != nil {
|
||||
continue // best effort; keep whatever other chunks returned
|
||||
}
|
||||
maps.Copy(scores, m)
|
||||
}
|
||||
if len(scores) > 0 {
|
||||
a.applyEPSS(scores)
|
||||
}
|
||||
}
|
||||
|
||||
// epssCandidates returns the unique validated CVE ids currently held in the CVE and KEV sets.
|
||||
func (a *Aggregator) epssCandidates() []string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
seen := map[string]bool{}
|
||||
var ids []string
|
||||
for _, cat := range []Category{CategoryCVE, CategoryKEV} {
|
||||
for _, it := range a.items[cat] {
|
||||
if id, ok := cleanCVEID(it.ID); ok && !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (a *Aggregator) fetchEPSS(ctx context.Context, ids []string) (map[string]float64, error) {
|
||||
q := url.Values{}
|
||||
q.Set("cve", strings.Join(ids, ","))
|
||||
q.Set("pretty", "false")
|
||||
raw, err := a.fetch(ctx, epssBaseURL+"?"+q.Encode(), epssLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp epssResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parsing EPSS response: %w", err)
|
||||
}
|
||||
out := make(map[string]float64, len(resp.Data))
|
||||
for _, d := range resp.Data {
|
||||
id, ok := cleanCVEID(d.CVE)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(d.EPSS), 64); err == nil {
|
||||
out[id] = clampUnit(v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// applyEPSS writes scores onto the matching CVE + KEV items in place.
|
||||
func (a *Aggregator) applyEPSS(scores map[string]float64) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, cat := range []Category{CategoryCVE, CategoryKEV} {
|
||||
list := a.items[cat]
|
||||
for i := range list {
|
||||
if s, ok := scores[list[i].ID]; ok {
|
||||
list[i].EPSS = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clampUnit(v float64) float64 {
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v > 1:
|
||||
return 1
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func chunkStrings(s []string, n int) [][]string {
|
||||
if n <= 0 || len(s) <= n {
|
||||
return [][]string{s}
|
||||
}
|
||||
var out [][]string
|
||||
for i := 0; i < len(s); i += n {
|
||||
out = append(out, s[i:min(i+n, len(s))])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CISA Known Exploited Vulnerabilities catalog: one JSON file, no auth. It is the most
|
||||
// actionable feed — every entry is a vulnerability being exploited in the wild. The file is
|
||||
// a couple of megabytes and grows monotonically, which is why its read cap (kevLimit) is far
|
||||
// above the updater's 1 MB: a truncated file would fail to parse forever.
|
||||
|
||||
type kevFile struct {
|
||||
Vulnerabilities []kevVuln `json:"vulnerabilities"`
|
||||
}
|
||||
|
||||
type kevVuln struct {
|
||||
CVEID string `json:"cveID"`
|
||||
VendorProject string `json:"vendorProject"`
|
||||
Product string `json:"product"`
|
||||
VulnerabilityName string `json:"vulnerabilityName"`
|
||||
DateAdded string `json:"dateAdded"`
|
||||
ShortDescription string `json:"shortDescription"`
|
||||
KnownRansomware string `json:"knownRansomwareCampaignUse"`
|
||||
}
|
||||
|
||||
// parseKEV normalizes the KEV catalog, newest additions first (sorting happens in the
|
||||
// aggregator). The URL is derived from the cleaned CVE id, never taken from the file.
|
||||
func parseKEV(raw []byte) ([]Item, error) {
|
||||
var f kevFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, fmt.Errorf("parsing KEV catalog: %w", err)
|
||||
}
|
||||
items := make([]Item, 0, len(f.Vulnerabilities))
|
||||
for _, v := range f.Vulnerabilities {
|
||||
id, ok := cleanCVEID(v.CVEID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, Item{
|
||||
Category: CategoryKEV,
|
||||
ID: id,
|
||||
Title: sanitizeText(v.VulnerabilityName, maxTitle),
|
||||
Summary: sanitizeText(v.ShortDescription, maxSummary),
|
||||
Vendor: sanitizeText(v.VendorProject, maxVendor),
|
||||
Product: sanitizeText(v.Product, maxProduct),
|
||||
Ransomware: strings.EqualFold(strings.TrimSpace(v.KnownRansomware), "Known"),
|
||||
URL: nvdCanonicalURL(id),
|
||||
Published: normalizeTime(parseKEVDate(v.DateAdded)),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package feeds aggregates curated external threat-intelligence sources (high-severity
|
||||
// CVEs, the CISA KEV catalog, and reputable security advisory headlines) into a single
|
||||
// normalized, in-memory snapshot the dashboard renders.
|
||||
//
|
||||
// It is a leaf package: it imports only the standard library and internal/atomicfile. The
|
||||
// HTTP client is injected as a FetchFunc, the same way internal/webhook receives its client
|
||||
// factory, so this package never reaches internal/proxy and stays supply-chain clean.
|
||||
//
|
||||
// Feed bytes are untrusted and are data, never instructions. Two guarantees keep that true:
|
||||
// - normalize.go is the firewall every value crosses before it reaches the snapshot: it
|
||||
// emerges a bounded, validated scalar (see that file's doc comment). This stops
|
||||
// browser-side content injection — the UI's origin is fully privileged.
|
||||
// - Nothing read off the wire reaches a code-execution sink. This package must never import
|
||||
// os/exec, text/template, html/template, database/sql, internal/localcmd, or
|
||||
// internal/jsruntime; no feed-derived string is ever a format argument; the one
|
||||
// feed-derived outbound URL (EPSS) is built from already-validated CVE ids; and only the
|
||||
// operator's settings — never feed content — are written to disk. That is what makes feed
|
||||
// ingestion safe against server-side command/template/SQL injection.
|
||||
package feeds
|
||||
|
||||
import "time"
|
||||
|
||||
// Category groups items by what they are, which is also how the dashboard lays them out.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryCVE Category = "cve" // high-severity CVEs (the ticker)
|
||||
CategoryKEV Category = "kev" // CISA Known Exploited Vulnerabilities
|
||||
CategoryHeadline Category = "headline" // curated advisory/news headlines
|
||||
)
|
||||
|
||||
// allCategories is the full set, in display order. Snapshot always carries every key.
|
||||
var allCategories = []Category{CategoryCVE, CategoryKEV, CategoryHeadline}
|
||||
|
||||
// Item is one normalized feed entry. Every field is a bounded scalar that has passed
|
||||
// normalize.go: strings are length-capped plain UTF-8 with no control/BiDi/zero-width
|
||||
// runes, Severity is an enum, Score is in [0,10], and URL is an https link on a known host
|
||||
// (or empty). No []byte, json.RawMessage, or interface value ever reaches this struct, so
|
||||
// the frontend can render every field as an auto-escaped React text node.
|
||||
type Item struct {
|
||||
Category Category `json:"category"`
|
||||
ID string `json:"id"` // CVE id, or a derived hash for a headline
|
||||
Title string `json:"title"` // short, plain text
|
||||
Summary string `json:"summary,omitempty"` // plain text; never a feed's HTML body
|
||||
Vendor string `json:"vendor,omitempty"` // "" means unknown
|
||||
Product string `json:"product,omitempty"` //
|
||||
Severity string `json:"severity,omitempty"` // "HIGH" | "CRITICAL" only
|
||||
Score float64 `json:"score,omitempty"` // CVSS base score, [0,10]
|
||||
EPSS float64 `json:"epss,omitempty"` // EPSS probability of exploitation, [0,1]
|
||||
Ransomware bool `json:"ransomware,omitempty"` // KEV: known ransomware-campaign use
|
||||
AlsoInKEV bool `json:"alsoInKEV,omitempty"` // a CVE that also appears in the KEV catalog
|
||||
URL string `json:"url,omitempty"` // validated https, or "" (render as text)
|
||||
Source string `json:"source"` // the registry's display name, never feed bytes
|
||||
Published time.Time `json:"published"`
|
||||
}
|
||||
|
||||
// SourceStatus reports how one source's last fetch went, so the UI can show "stale since X"
|
||||
// rather than silently presenting old data as current.
|
||||
type SourceStatus struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category Category `json:"category"`
|
||||
LastAttempt time.Time `json:"lastAttempt"`
|
||||
LastSuccess time.Time `json:"lastSuccess"`
|
||||
LastError string `json:"lastError,omitempty"` // sanitized, capped
|
||||
ItemCount int `json:"itemCount"`
|
||||
Stale bool `json:"stale"` // last attempt failed since the last success
|
||||
}
|
||||
|
||||
// Snapshot is the whole aggregated state the API returns and the UI renders. Items always
|
||||
// carries all three category keys (possibly empty arrays) so the client never indexes nil.
|
||||
type Snapshot struct {
|
||||
Items map[Category][]Item `json:"items"`
|
||||
Sources []SourceStatus `json:"sources"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// normalize.go is the injection firewall.
|
||||
//
|
||||
// Feed bytes are untrusted and the UI origin is fully privileged, so nothing reaches an Item
|
||||
// except through one of these functions. The contract each enforces:
|
||||
//
|
||||
// - sanitizeText: bounded plain UTF-8, whitespace collapsed, every control / BiDi-override
|
||||
// / zero-width / non-printable rune dropped. This is what lets the frontend render a value
|
||||
// as a text node without escaping surprises — a hostile "<img onerror=…>" lands as literal
|
||||
// text, and a "" right-to-left override cannot reorder a line.
|
||||
// - cleanCVEID: a CVE id or nothing. Everything CVE-derived is gated on it, and a canonical
|
||||
// URL is *built* from the cleaned id rather than trusted from the feed.
|
||||
// - validateURL: https on an allow-listed host, or "" (meaning "render as text, no link").
|
||||
// Used only for headline links, where no canonical URL can be derived.
|
||||
// - the enum/clamp helpers: Severity is HIGH|CRITICAL or "", Score is clamped to [0,10],
|
||||
// a future Published is clamped to now.
|
||||
const (
|
||||
maxID = 64
|
||||
maxTitle = 300
|
||||
maxSummary = 1000
|
||||
maxVendor = 128
|
||||
maxProduct = 128
|
||||
maxSource = 64
|
||||
maxURL = 512
|
||||
maxError = 256
|
||||
)
|
||||
|
||||
var cveIDPattern = regexp.MustCompile(`^CVE-\d{4}-\d{4,}$`)
|
||||
|
||||
// sanitizeText scrubs and bounds a string decoded by encoding/json or encoding/xml.
|
||||
//
|
||||
// Whitespace (any Unicode space, including tab/newline/NBSP) collapses to a single ASCII
|
||||
// space; every other non-printable rune — C0/C1 controls, zero-width marks, BiDi overrides,
|
||||
// invalid UTF-8 — is dropped. The result is trimmed and truncated on a rune boundary.
|
||||
func sanitizeText(s string, max int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
lastSpace := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == utf8.RuneError:
|
||||
continue // invalid byte sequence
|
||||
case unicode.IsSpace(r):
|
||||
if !lastSpace {
|
||||
b.WriteByte(' ')
|
||||
lastSpace = true
|
||||
}
|
||||
case !unicode.IsPrint(r):
|
||||
continue // controls, zero-width, BiDi overrides (all category C)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
lastSpace = false
|
||||
}
|
||||
}
|
||||
return truncateRunes(strings.TrimSpace(b.String()), max)
|
||||
}
|
||||
|
||||
// truncateRunes caps a string to max runes, appending an ellipsis when it cuts.
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
if max == 1 {
|
||||
return "…"
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:max-1])) + "…"
|
||||
}
|
||||
|
||||
// cleanCVEID returns the canonical upper-cased CVE id, or ("", false) for anything that is
|
||||
// not one. The gate for every CVE- and KEV-derived value.
|
||||
func cleanCVEID(s string) (string, bool) {
|
||||
id := strings.ToUpper(strings.TrimSpace(s))
|
||||
if len(id) > maxID || !cveIDPattern.MatchString(id) {
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// nvdCanonicalURL builds the authoritative detail URL from an already-cleaned CVE id. The
|
||||
// feed's own link/reference fields are never used for CVE or KEV items, so a feed cannot
|
||||
// smuggle a hostile URL in through them.
|
||||
func nvdCanonicalURL(id string) string {
|
||||
return "https://nvd.nist.gov/vuln/detail/" + id
|
||||
}
|
||||
|
||||
// validateURL accepts an https link whose host is on allow, stripped to scheme+host+path.
|
||||
// Anything else — a non-https scheme (javascript:, data:, http:), userinfo, a non-443 port,
|
||||
// an off-list host, an unparseable or over-long value — returns ("", false), which callers
|
||||
// treat as "no link, render the title as plain text".
|
||||
func validateURL(raw string, allow []string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > maxURL {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !strings.EqualFold(u.Scheme, "https") || u.User != nil {
|
||||
return "", false
|
||||
}
|
||||
if p := u.Port(); p != "" && p != "443" {
|
||||
return "", false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "" || !hostAllowed(host, allow) {
|
||||
return "", false
|
||||
}
|
||||
clean := url.URL{Scheme: "https", Host: host, Path: u.EscapedPath()}
|
||||
out := clean.String()
|
||||
if len(out) > maxURL {
|
||||
return "", false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// hostAllowed reports whether host equals or is a subdomain of an allow-list entry.
|
||||
func hostAllowed(host string, allow []string) bool {
|
||||
for _, a := range allow {
|
||||
a = strings.ToLower(strings.TrimSpace(a))
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if host == a || strings.HasSuffix(host, "."+a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// headlineID derives a stable id for a headline from its source and link, so the same
|
||||
// article dedups across refreshes without trusting any id the feed supplies.
|
||||
func headlineID(source, link string) string {
|
||||
sum := sha256.Sum256([]byte(source + "|" + link))
|
||||
return hex.EncodeToString(sum[:])[:32]
|
||||
}
|
||||
|
||||
// cleanSeverity passes only the two buckets at or above our floor; everything else is "".
|
||||
func cleanSeverity(s string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "HIGH":
|
||||
return "HIGH"
|
||||
case "CRITICAL":
|
||||
return "CRITICAL"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// deriveSeverity buckets a score when a feed gives none (e.g. CVSS v2). Returns "" below the
|
||||
// HIGH floor, which the score filter rejects anyway.
|
||||
func deriveSeverity(score float64) string {
|
||||
switch {
|
||||
case score >= 9.0:
|
||||
return "CRITICAL"
|
||||
case score >= 7.0:
|
||||
return "HIGH"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// clampScore bounds a CVSS score into the valid range.
|
||||
func clampScore(s float64) float64 {
|
||||
switch {
|
||||
case s < 0:
|
||||
return 0
|
||||
case s > 10:
|
||||
return 10
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeTime returns t in UTC, clamping a future timestamp to now so one mis-stamped item
|
||||
// cannot dominate a newest-first sort.
|
||||
func normalizeTime(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return t
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if t.After(now) {
|
||||
return now
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
func parseNVDTime(s string) time.Time {
|
||||
for _, layout := range []string{
|
||||
"2006-01-02T15:04:05.000",
|
||||
"2006-01-02T15:04:05",
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
} {
|
||||
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseKEVDate(s string) time.Time {
|
||||
if t, err := time.Parse("2006-01-02", strings.TrimSpace(s)); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseFeedTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, layout := range []string{
|
||||
time.RFC1123Z, time.RFC1123, time.RFC3339Nano, time.RFC3339, time.RFC822Z, time.RFC822,
|
||||
// Weekday + two-digit year variants (CISA's all.xml uses "Tue, 13 Oct 26 12:00:00 +0000").
|
||||
"Mon, 02 Jan 06 15:04:05 -0700",
|
||||
"Mon, 02 Jan 06 15:04:05 MST",
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NVD CVE API 2.0 (https://services.nvd.nist.gov/rest/json/cves/2.0).
|
||||
//
|
||||
// The unauthenticated rate limit is 5 requests / 30s, so nvdCollect makes just two requests
|
||||
// per refresh — one for CRITICAL, one for HIGH — over a 24-hour publication window, paced 6s
|
||||
// apart. The window is wider than the 15-minute refresh so the ticker has depth and a missed
|
||||
// tick self-heals; the aggregator's rolling cache dedups by CVE id. The API buckets only by
|
||||
// severity, so the 7.5 floor is applied here.
|
||||
|
||||
// nvdCollect fetches recent high-severity CVEs and parses them into normalized items.
|
||||
func nvdCollect(ctx context.Context, fetch FetchFunc) ([]Item, error) {
|
||||
end := time.Now().UTC()
|
||||
start := end.Add(-24 * time.Hour)
|
||||
|
||||
var items []Item
|
||||
var firstErr error
|
||||
for i, sev := range []string{"CRITICAL", "HIGH"} {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return items, ctx.Err()
|
||||
case <-time.After(6 * time.Second): // stay well under 5 req / 30s
|
||||
}
|
||||
}
|
||||
got, err := nvdFetchSeverity(ctx, fetch, sev, start, end)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, got...)
|
||||
}
|
||||
// Only surface an error when nothing came back at all; a partial result (one severity
|
||||
// succeeded) is better than blanking the category.
|
||||
if len(items) == 0 && firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func nvdFetchSeverity(ctx context.Context, fetch FetchFunc, severity string, start, end time.Time) ([]Item, error) {
|
||||
const nvdTimeLayout = "2006-01-02T15:04:05.000"
|
||||
q := url.Values{}
|
||||
q.Set("cvssV3Severity", severity)
|
||||
q.Set("pubStartDate", start.Format(nvdTimeLayout))
|
||||
q.Set("pubEndDate", end.Format(nvdTimeLayout))
|
||||
q.Set("resultsPerPage", "2000") // a day of high-sev CVEs fits in one page
|
||||
raw, err := fetch(ctx, nvdBaseURL+"?"+q.Encode(), nvdLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseNVD(raw)
|
||||
}
|
||||
|
||||
type nvdResponse struct {
|
||||
Vulnerabilities []nvdVuln `json:"vulnerabilities"`
|
||||
}
|
||||
|
||||
type nvdVuln struct {
|
||||
CVE nvdCVE `json:"cve"`
|
||||
}
|
||||
|
||||
type nvdCVE struct {
|
||||
ID string `json:"id"`
|
||||
Published string `json:"published"`
|
||||
Descriptions []nvdLangString `json:"descriptions"`
|
||||
Metrics nvdMetrics `json:"metrics"`
|
||||
Configurations []nvdConfig `json:"configurations"`
|
||||
Affected []nvdAffected `json:"affected"`
|
||||
}
|
||||
|
||||
type nvdLangString struct {
|
||||
Lang string `json:"lang"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type nvdMetrics struct {
|
||||
V31 []nvdCVSSMetric `json:"cvssMetricV31"`
|
||||
V30 []nvdCVSSMetric `json:"cvssMetricV30"`
|
||||
V2 []nvdCVSSMetric `json:"cvssMetricV2"`
|
||||
}
|
||||
|
||||
type nvdCVSSMetric struct {
|
||||
Type string `json:"type"` // "Primary" | "Secondary"
|
||||
CVSSData struct {
|
||||
BaseScore float64 `json:"baseScore"`
|
||||
BaseSeverity string `json:"baseSeverity"` // absent on v2
|
||||
} `json:"cvssData"`
|
||||
}
|
||||
|
||||
type nvdConfig struct {
|
||||
Nodes []nvdNode `json:"nodes"`
|
||||
}
|
||||
|
||||
type nvdNode struct {
|
||||
CPEMatch []nvdCPEMatch `json:"cpeMatch"`
|
||||
}
|
||||
|
||||
type nvdCPEMatch struct {
|
||||
Criteria string `json:"criteria"`
|
||||
}
|
||||
|
||||
// nvdAffected is the CNA-provided affected-products block NVD 2.0 carries even before it
|
||||
// runs its own CPE analysis — the real affected vendor/product for a freshly published CVE.
|
||||
type nvdAffected struct {
|
||||
AffectedData []nvdAffectedData `json:"affectedData"`
|
||||
}
|
||||
|
||||
type nvdAffectedData struct {
|
||||
Vendor string `json:"vendor"`
|
||||
Product string `json:"product"`
|
||||
}
|
||||
|
||||
// parseNVD normalizes an NVD response, keeping only CVEs scoring >= 7.5.
|
||||
func parseNVD(raw []byte) ([]Item, error) {
|
||||
var resp nvdResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parsing NVD response: %w", err)
|
||||
}
|
||||
items := make([]Item, 0, len(resp.Vulnerabilities))
|
||||
for _, v := range resp.Vulnerabilities {
|
||||
id, ok := cleanCVEID(v.CVE.ID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
score, severity, ok := nvdScore(v.CVE.Metrics)
|
||||
if !ok || score < 7.5 {
|
||||
continue
|
||||
}
|
||||
vendor, product := nvdVendorProduct(v.CVE)
|
||||
items = append(items, Item{
|
||||
Category: CategoryCVE,
|
||||
ID: id,
|
||||
Title: sanitizeText(nvdDescription(v.CVE.Descriptions), maxTitle),
|
||||
Vendor: sanitizeText(vendor, maxVendor),
|
||||
Product: sanitizeText(product, maxProduct),
|
||||
Severity: severity,
|
||||
Score: score,
|
||||
URL: nvdCanonicalURL(id),
|
||||
Published: normalizeTime(parseNVDTime(v.CVE.Published)),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// nvdScore picks the base score and severity, preferring CVSS v3.1, then v3.0, then v2. A v2
|
||||
// metric has no severity bucket, so it is derived from the score.
|
||||
func nvdScore(m nvdMetrics) (float64, string, bool) {
|
||||
if s, sev, ok := pickCVSS(m.V31); ok {
|
||||
return s, sev, true
|
||||
}
|
||||
if s, sev, ok := pickCVSS(m.V30); ok {
|
||||
return s, sev, true
|
||||
}
|
||||
for _, e := range m.V2 {
|
||||
sc := clampScore(e.CVSSData.BaseScore)
|
||||
return sc, deriveSeverity(sc), true
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
func pickCVSS(metrics []nvdCVSSMetric) (float64, string, bool) {
|
||||
if len(metrics) == 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
chosen := metrics[0]
|
||||
for _, e := range metrics {
|
||||
if strings.EqualFold(e.Type, "Primary") {
|
||||
chosen = e
|
||||
break
|
||||
}
|
||||
}
|
||||
score := clampScore(chosen.CVSSData.BaseScore)
|
||||
sev := cleanSeverity(chosen.CVSSData.BaseSeverity)
|
||||
if sev == "" {
|
||||
sev = deriveSeverity(score)
|
||||
}
|
||||
return score, sev, true
|
||||
}
|
||||
|
||||
// nvdVendorProduct reads the affected vendor/product, preferring NVD's analyzed CPE and
|
||||
// falling back to the CNA-provided affected-products block.
|
||||
//
|
||||
// CPE first: it is NVD-normalized (so the vendor/product align with the CPE strings Target
|
||||
// Exposure correlates against) but a freshly published CVE — the ticker's 24h window — usually
|
||||
// has none yet. For those, `cve.affected[].affectedData[]` carries the real affected vendor
|
||||
// (e.g. "printcart"), which NVD includes in the same response, so no extra request is needed.
|
||||
// This is the affected vendor, not the CNA that assigned the CVE (that would be "wordfence" /
|
||||
// "vulncheck" — wrong). "" (shown as no vendor) only when a CVE has neither, which is rare.
|
||||
func nvdVendorProduct(c nvdCVE) (string, string) {
|
||||
for _, cfg := range c.Configurations {
|
||||
for _, node := range cfg.Nodes {
|
||||
for _, m := range node.CPEMatch {
|
||||
if v, p, ok := cpeVendorProduct(m.Criteria); ok {
|
||||
return v, p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, a := range c.Affected {
|
||||
for _, ad := range a.AffectedData {
|
||||
if v := strings.TrimSpace(ad.Vendor); v != "" && !strings.EqualFold(v, "n/a") {
|
||||
return v, strings.TrimSpace(ad.Product)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// cpeVendorProduct parses the vendor and product out of a CPE 2.3 formatted string
|
||||
// (cpe:2.3:a:vendor:product:version:...). Returns ok=false for anything not in that shape.
|
||||
func cpeVendorProduct(criteria string) (vendor, product string, ok bool) {
|
||||
parts := strings.Split(criteria, ":")
|
||||
if len(parts) < 6 || parts[0] != "cpe" || parts[1] != "2.3" {
|
||||
return "", "", false
|
||||
}
|
||||
vendor = cpeField(parts[3])
|
||||
product = cpeField(parts[4])
|
||||
if vendor == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return vendor, product, true
|
||||
}
|
||||
|
||||
// cpeField decodes one CPE 2.3 field: "*"/"-" mean unset, "_" separates words, "\" escapes.
|
||||
func cpeField(f string) string {
|
||||
if f == "" || f == "*" || f == "-" {
|
||||
return ""
|
||||
}
|
||||
f = strings.ReplaceAll(f, `\`, "")
|
||||
return strings.ReplaceAll(f, "_", " ")
|
||||
}
|
||||
|
||||
func nvdDescription(ds []nvdLangString) string {
|
||||
for _, d := range ds {
|
||||
if strings.EqualFold(d.Lang, "en") {
|
||||
return d.Value
|
||||
}
|
||||
}
|
||||
if len(ds) > 0 {
|
||||
return ds[0].Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package feeds
|
||||
|
||||
import "context"
|
||||
|
||||
// The curated source registry. Feed URLs are compile-time constants, the same way the
|
||||
// updater pins its GitHub destination: no operator-supplied URL ever reaches the fetcher, so
|
||||
// this feature is not an arbitrary-outbound-on-a-timer primitive and needs no SSRF/private-IP
|
||||
// egress guard (there is none in the codebase to borrow). Adding a headline source means
|
||||
// adding a vetted host to its allow-list here — never a field the operator can set.
|
||||
const (
|
||||
nvdBaseURL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
nvdLimit = 8 << 20
|
||||
|
||||
kevURL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
|
||||
kevLimit = 8 << 20 // the KEV file is ~2 MB and grows; far above the updater's 1 MB
|
||||
|
||||
rssLimit = 2 << 20
|
||||
)
|
||||
|
||||
// Source is one feed. collect encapsulates how it is fetched and parsed (NVD needs two
|
||||
// requests; KEV and RSS one), so the aggregator treats every source uniformly.
|
||||
type Source struct {
|
||||
ID string
|
||||
Name string
|
||||
Category Category
|
||||
collect func(ctx context.Context, fetch FetchFunc) ([]Item, error)
|
||||
}
|
||||
|
||||
// DefaultSources is the built-in feed set.
|
||||
func DefaultSources() []Source {
|
||||
return []Source{
|
||||
{
|
||||
ID: "nvd-cve",
|
||||
Name: "NVD",
|
||||
Category: CategoryCVE,
|
||||
collect: nvdCollect,
|
||||
},
|
||||
{
|
||||
ID: "cisa-kev",
|
||||
Name: "CISA KEV",
|
||||
Category: CategoryKEV,
|
||||
collect: func(ctx context.Context, fetch FetchFunc) ([]Item, error) {
|
||||
raw, err := fetch(ctx, kevURL, kevLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseKEV(raw)
|
||||
},
|
||||
},
|
||||
rssSource("cisa-advisories", "CISA Advisories", CategoryHeadline,
|
||||
"https://www.cisa.gov/cybersecurity-advisories/all.xml",
|
||||
[]string{"cisa.gov"}),
|
||||
// The us-cert.cisa.gov/ncas/analysis-reports.xml form 301-redirects here; feedsFetch
|
||||
// does not follow redirects, so the resolved URL is pinned directly.
|
||||
rssSource("cisa-analysis-reports", "CISA Analysis Reports", CategoryHeadline,
|
||||
"https://www.cisa.gov/cybersecurity-advisories/analysis-reports.xml",
|
||||
[]string{"cisa.gov"}),
|
||||
rssSource("bishopfox-blog", "Bishop Fox Blog", CategoryHeadline,
|
||||
"https://bishopfox.com/feeds/blog.rss",
|
||||
[]string{"bishopfox.com"}),
|
||||
rssSource("cert-cc", "CERT/CC", CategoryHeadline,
|
||||
"https://www.kb.cert.org/vulfeed/",
|
||||
[]string{"kb.cert.org"}),
|
||||
}
|
||||
}
|
||||
|
||||
func rssSource(id, name string, category Category, feedURL string, allowHosts []string) Source {
|
||||
return Source{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Category: category,
|
||||
collect: func(ctx context.Context, fetch FetchFunc) ([]Item, error) {
|
||||
raw, err := fetch(ctx, feedURL, rssLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseFeed(raw, name, allowHosts)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Headline feeds, RSS 2.0 / RSS 1.0 / Atom. Only the title, link, and date are read — never
|
||||
// a <description>/<content>/<summary> body, which is where a feed's HTML lives. The link is
|
||||
// the one value that cannot be derived, so it runs through validateURL against the source's
|
||||
// host allow-list; a link that fails validation yields an item with an empty URL (shown as
|
||||
// plain text, no anchor) rather than being dropped.
|
||||
//
|
||||
// encoding/xml does not fetch external entities or process DTDs, so XXE is not reachable;
|
||||
// Entity is pinned to the known HTML named-entity set and the input is already bounded by the
|
||||
// fetch LimitReader, which is what actually caps any entity expansion.
|
||||
|
||||
type feedKind int
|
||||
|
||||
const (
|
||||
feedUnknown feedKind = iota
|
||||
feedRSS
|
||||
feedAtom
|
||||
)
|
||||
|
||||
type rssFeed struct {
|
||||
Channel struct {
|
||||
Items []rssItem `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
// RSS 1.0 (RDF) places items as siblings of channel, not inside it.
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Date string `xml:"date"` // dc:date (RSS 1.0)
|
||||
}
|
||||
|
||||
type atomFeed struct {
|
||||
Entries []atomEntry `xml:"entry"`
|
||||
}
|
||||
|
||||
type atomEntry struct {
|
||||
Title string `xml:"title"`
|
||||
Links []atomLink `xml:"link"`
|
||||
Updated string `xml:"updated"`
|
||||
Published string `xml:"published"`
|
||||
}
|
||||
|
||||
type atomLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
}
|
||||
|
||||
// parseFeed sniffs the root element and dispatches to the RSS or Atom parser.
|
||||
func parseFeed(raw []byte, sourceName string, allowHosts []string) ([]Item, error) {
|
||||
switch sniffFeed(raw) {
|
||||
case feedAtom:
|
||||
return parseAtom(raw, sourceName, allowHosts)
|
||||
case feedRSS:
|
||||
return parseRSS(raw, sourceName, allowHosts)
|
||||
default:
|
||||
return nil, fmt.Errorf("unrecognized feed format for %q", sourceName)
|
||||
}
|
||||
}
|
||||
|
||||
func newXMLDecoder(raw []byte) *xml.Decoder {
|
||||
dec := xml.NewDecoder(bytes.NewReader(raw))
|
||||
dec.Strict = false // real-world feeds are not always well-formed
|
||||
dec.Entity = xml.HTMLEntity // known named entities only; no custom general-entity expansion
|
||||
return dec
|
||||
}
|
||||
|
||||
func sniffFeed(raw []byte) feedKind {
|
||||
dec := newXMLDecoder(raw)
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return feedUnknown
|
||||
}
|
||||
if se, ok := tok.(xml.StartElement); ok {
|
||||
switch strings.ToLower(se.Name.Local) {
|
||||
case "feed":
|
||||
return feedAtom
|
||||
case "rss", "rdf":
|
||||
return feedRSS
|
||||
default:
|
||||
return feedUnknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseRSS(raw []byte, sourceName string, allowHosts []string) ([]Item, error) {
|
||||
var feed rssFeed
|
||||
if err := newXMLDecoder(raw).Decode(&feed); err != nil {
|
||||
return nil, fmt.Errorf("parsing RSS for %q: %w", sourceName, err)
|
||||
}
|
||||
all := append(feed.Channel.Items, feed.Items...)
|
||||
items := make([]Item, 0, len(all))
|
||||
for _, it := range all {
|
||||
items = append(items, headlineItem(sourceName, it.Title, it.Link, firstNonEmpty(it.PubDate, it.Date), allowHosts))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseAtom(raw []byte, sourceName string, allowHosts []string) ([]Item, error) {
|
||||
var feed atomFeed
|
||||
if err := newXMLDecoder(raw).Decode(&feed); err != nil {
|
||||
return nil, fmt.Errorf("parsing Atom for %q: %w", sourceName, err)
|
||||
}
|
||||
items := make([]Item, 0, len(feed.Entries))
|
||||
for _, e := range feed.Entries {
|
||||
link := atomPrimaryLink(e.Links)
|
||||
items = append(items, headlineItem(sourceName, e.Title, link, firstNonEmpty(e.Published, e.Updated), allowHosts))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// atomPrimaryLink picks the article link: rel="alternate", else the first rel-less link.
|
||||
func atomPrimaryLink(links []atomLink) string {
|
||||
for _, l := range links {
|
||||
if strings.EqualFold(l.Rel, "alternate") {
|
||||
return l.Href
|
||||
}
|
||||
}
|
||||
for _, l := range links {
|
||||
if l.Rel == "" {
|
||||
return l.Href
|
||||
}
|
||||
}
|
||||
if len(links) > 0 {
|
||||
return links[0].Href
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func headlineItem(sourceName, title, link, dateStr string, allowHosts []string) Item {
|
||||
cleanLink, _ := validateURL(link, allowHosts)
|
||||
return Item{
|
||||
Category: CategoryHeadline,
|
||||
ID: headlineID(sourceName, link),
|
||||
Title: sanitizeText(title, maxTitle),
|
||||
URL: cleanLink,
|
||||
Source: sanitizeText(sourceName, maxSource),
|
||||
Published: normalizeTime(parseFeedTime(dateStr)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package feeds
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/BishopFox/joro/internal/atomicfile"
|
||||
)
|
||||
|
||||
// Feed settings persist to ~/.joro/feeds.json (0600).
|
||||
//
|
||||
// Off by default, and a store of its own rather than a field on the session Settings or the
|
||||
// project config, for the reason the MCP toggle is: enabling feeds starts outbound traffic
|
||||
// from the operator's IP, so the choice must survive a restart and must not travel to a
|
||||
// teammate inside a project file. The snapshot/cache itself is never persisted — it is
|
||||
// re-fetched after a restart.
|
||||
|
||||
const (
|
||||
settingsFileVersion = 1
|
||||
// MinIntervalMinutes floors the refresh so Settings cannot drive NVD into its rate limit.
|
||||
MinIntervalMinutes = 15
|
||||
DefaultIntervalMinutes = 15
|
||||
)
|
||||
|
||||
// State is the operator's feed configuration.
|
||||
type State struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Categories map[Category]bool `json:"categories"`
|
||||
IntervalMinutes int `json:"intervalMinutes"`
|
||||
// RouteThroughSocks sends feed egress through the operator's configured SOCKS proxy.
|
||||
// Off by default: feed traffic (NVD/CISA/ExploitDB/FIRST.org) is incidental intel, not
|
||||
// engagement traffic, so it should not consume or reveal the operator's tunnel unless
|
||||
// they ask. Honored only when a SOCKS proxy is actually configured.
|
||||
RouteThroughSocks bool `json:"routeThroughSocks"`
|
||||
}
|
||||
|
||||
// DefaultState is feeds on, all categories armed, at the floor interval, direct egress.
|
||||
func DefaultState() State {
|
||||
return State{
|
||||
Enabled: true,
|
||||
Categories: map[Category]bool{
|
||||
CategoryCVE: true,
|
||||
CategoryKEV: true,
|
||||
CategoryHeadline: true,
|
||||
},
|
||||
IntervalMinutes: DefaultIntervalMinutes,
|
||||
RouteThroughSocks: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (st State) clone() State {
|
||||
cats := make(map[Category]bool, len(st.Categories))
|
||||
for k, v := range st.Categories {
|
||||
cats[k] = v
|
||||
}
|
||||
st.Categories = cats
|
||||
return st
|
||||
}
|
||||
|
||||
// normalizeState clamps an incoming or on-disk state to a known shape: every category key
|
||||
// present, the interval floored.
|
||||
func normalizeState(st State) State {
|
||||
out := DefaultState()
|
||||
out.Enabled = st.Enabled
|
||||
out.RouteThroughSocks = st.RouteThroughSocks
|
||||
if st.Categories != nil {
|
||||
out.Categories = map[Category]bool{
|
||||
CategoryCVE: st.Categories[CategoryCVE],
|
||||
CategoryKEV: st.Categories[CategoryKEV],
|
||||
CategoryHeadline: st.Categories[CategoryHeadline],
|
||||
}
|
||||
}
|
||||
if st.IntervalMinutes >= MinIntervalMinutes {
|
||||
out.IntervalMinutes = st.IntervalMinutes
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type settingsFile struct {
|
||||
Version int `json:"version"`
|
||||
State State `json:"state"`
|
||||
}
|
||||
|
||||
// SettingsStore reads and writes feeds.json.
|
||||
type SettingsStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
state State
|
||||
}
|
||||
|
||||
// NewSettingsStore opens feeds.json, creating nothing until the first write. A file that will
|
||||
// not parse is a hard error rather than a silent default, the same discipline the webhook and
|
||||
// trigger stores use.
|
||||
func NewSettingsStore(dir string) (*SettingsStore, error) {
|
||||
s := &SettingsStore{
|
||||
path: filepath.Join(dir, "feeds.json"),
|
||||
state: DefaultState(),
|
||||
}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", s.path, err)
|
||||
}
|
||||
var f settingsFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", s.path, err)
|
||||
}
|
||||
s.state = normalizeState(f.State)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Get returns a copy of the current state.
|
||||
func (s *SettingsStore) Get() State {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.state.clone()
|
||||
}
|
||||
|
||||
// Set normalizes, stores, and persists a new state, returning the stored copy.
|
||||
func (s *SettingsStore) Set(st State) (State, error) {
|
||||
st = normalizeState(st)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prev := s.state
|
||||
s.state = st
|
||||
if err := s.flushLocked(); err != nil {
|
||||
s.state = prev
|
||||
return State{}, err
|
||||
}
|
||||
return s.state.clone(), nil
|
||||
}
|
||||
|
||||
func (s *SettingsStore) flushLocked() error {
|
||||
data, err := json.MarshalIndent(settingsFile{Version: settingsFileVersion, State: s.state}, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding feed settings: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicfile.Write(s.path, append(data, '\n'), 0o600)
|
||||
}
|
||||
@@ -14,6 +14,9 @@ type Tech struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
Confidence int `json:"confidence"`
|
||||
// CPE is the technology's CPE 2.3 string (vendor:product), when the database has one.
|
||||
// Surfaced so the UI can join a host's stack against CVE/KEV feeds by vendor/product.
|
||||
CPE string `json:"cpe,omitempty"`
|
||||
}
|
||||
|
||||
// HostTech is the technology set for one host.
|
||||
@@ -79,7 +82,7 @@ func (s *Store) Observe(host string, deep bool, dets []Detection) bool {
|
||||
cur := set[d.Name]
|
||||
if cur == nil {
|
||||
set[d.Name] = &Tech{
|
||||
Name: d.Name, Version: d.Version,
|
||||
Name: d.Name, Version: d.Version, CPE: d.CPE,
|
||||
Categories: d.Categories, Confidence: d.Confidence,
|
||||
}
|
||||
changed = true
|
||||
|
||||
@@ -36,6 +36,10 @@ type Detection struct {
|
||||
Version string
|
||||
Categories []string
|
||||
Confidence int
|
||||
// CPE is the technology's CPE 2.3 string from the Wappalyzer database (present on the
|
||||
// security-relevant subset), e.g. "cpe:2.3:a:f5:nginx:*:*:*:*:*:*:*:*". Empty when the
|
||||
// database has none. Surfaced so a consumer can join on vendor:product.
|
||||
CPE string
|
||||
}
|
||||
|
||||
// Input is the HTTP-observable surface of one response.
|
||||
@@ -57,6 +61,7 @@ type cpattern struct {
|
||||
// techDef is one technology's compiled rules.
|
||||
type techDef struct {
|
||||
name string
|
||||
cpe string
|
||||
cats []string
|
||||
headers map[string][]cpattern // canonical header name -> patterns
|
||||
cookies map[string][]cpattern // lower cookie name -> patterns
|
||||
@@ -132,6 +137,7 @@ type rawApp struct {
|
||||
ScriptSrc stringList `json:"scriptSrc"`
|
||||
URL stringList `json:"url"`
|
||||
Implies stringList `json:"implies"`
|
||||
CPE string `json:"cpe"`
|
||||
}
|
||||
|
||||
type rawCategory struct {
|
||||
@@ -156,7 +162,7 @@ func LoadWappalyzer() (*Wappalyzer, Stats, error) {
|
||||
w := &Wappalyzer{byName: make(map[string]*techDef, len(fp.Apps))}
|
||||
var st Stats
|
||||
for name, app := range fp.Apps {
|
||||
t := &techDef{name: name}
|
||||
t := &techDef{name: name, cpe: strings.TrimSpace(app.CPE)}
|
||||
for _, id := range app.Cats {
|
||||
if c, ok := cats[strconv.Itoa(id)]; ok && c.Name != "" {
|
||||
t.cats = append(t.cats, c.Name)
|
||||
@@ -277,7 +283,7 @@ func (w *Wappalyzer) Fingerprint(in Input) []Detection {
|
||||
mark := func(t *techDef, cp cpattern, m []string) {
|
||||
d := hits[t.name]
|
||||
if d == nil {
|
||||
d = &Detection{Name: t.name, Categories: t.cats, Confidence: cp.conf}
|
||||
d = &Detection{Name: t.name, CPE: t.cpe, Categories: t.cats, Confidence: cp.conf}
|
||||
hits[t.name] = d
|
||||
}
|
||||
if cp.conf > d.Confidence {
|
||||
@@ -379,10 +385,12 @@ func (w *Wappalyzer) resolveImplies(hits map[string]*Detection) {
|
||||
}
|
||||
it := w.byName[imp]
|
||||
cats := []string(nil)
|
||||
cpe := ""
|
||||
if it != nil {
|
||||
cats = it.cats
|
||||
cpe = it.cpe
|
||||
}
|
||||
hits[imp] = &Detection{Name: imp, Categories: cats, Confidence: 50}
|
||||
hits[imp] = &Detection{Name: imp, CPE: cpe, Categories: cats, Confidence: 50}
|
||||
queue = append(queue, imp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ func main() {
|
||||
flag.BoolVar(&cfg.AutomationScripting, "automation-scripting", false, "Expose script.run, which executes submitted JavaScript in a sandboxed worker process against Joro's automation SDK (off by default; still requires an explicit grant)")
|
||||
flag.BoolVar(&cfg.AutomationCommands, "automation-commands", false, "Allow installed command automations to run local operating-system commands, on a trigger or as a lens (off by default; operator-installed only, never reachable from an automation token)")
|
||||
flag.BoolVar(&cfg.NoWebhooks, "no-webhooks", false, "Disable outbound webhooks entirely (no routes, no webhooks.json, no outbound requests)")
|
||||
flag.BoolVar(&cfg.NoFeeds, "no-feeds", false, "Disable threat-intelligence feeds entirely (no routes, no feeds.json, no outbound requests to NVD/CISA)")
|
||||
flag.BoolVar(&cfg.NoPlugins, "no-plugins", false, "Do not load plugins from ~/.joro/plugins/. They are still listed in Settings so one can be deleted — use this to start after a plugin the binary cannot load")
|
||||
|
||||
// scriptWorker is how a script sandbox re-execs this binary. Not an operator
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
unavailableReason,
|
||||
type AvailabilityCtx,
|
||||
} from '../lib/dashboardWidgets'
|
||||
import { PRESETS, allSlots, presetOrDefault, type PresetId } from '../lib/dashboardPresets'
|
||||
import { PRESETS, presetOrDefault, type PresetId } from '../lib/dashboardPresets'
|
||||
import { layoutIncludes, useDashboardLayoutStore, type LayoutMode } from '../stores/dashboardLayoutStore'
|
||||
|
||||
const inputCls = 'bg-surface-input text-xs px-2 py-1 rounded-sm border border-border'
|
||||
@@ -45,9 +45,12 @@ export default function DashboardLayoutSettings({
|
||||
// Presence only applies to the team layout — it's the one in use when joined
|
||||
// to a team server.
|
||||
const hiddenFromTeam = editing === 'team' && !layoutIncludes(layout, 'team', 'team-chat')
|
||||
const preset = presetOrDefault(modeLayout.preset, editing === 'team' ? 'classic' : 'grid')
|
||||
const preset = presetOrDefault(modeLayout.preset, 'intel')
|
||||
// Availability for the layout being edited, which may not be the live one.
|
||||
const ctx: AvailabilityCtx = { mode: runMode, teamMode: editing === 'team' }
|
||||
// The Default layout ('intel') is curated and fixed — its slots can't be rearranged. The
|
||||
// Layout picker stays enabled so the operator can switch to a customizable preset.
|
||||
const locked = preset.id === 'intel'
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -113,13 +116,21 @@ export default function DashboardLayoutSettings({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{allSlots(preset).map((s) => (
|
||||
{locked && (
|
||||
<div className="border-l-2 border-semantic-warning pl-2.5 py-1 text-[10px] text-content-secondary leading-relaxed">
|
||||
The <span className="text-semantic-warning font-semibold">Default</span> layout is
|
||||
curated and can’t be rearranged. Pick another layout above to customize slots.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preset.slots.map((s) => (
|
||||
<div key={s.key} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<span className="text-xs text-content-secondary min-w-0 truncate">{s.label}</span>
|
||||
<select
|
||||
value={modeLayout.slots[s.key] ?? NO_WIDGET}
|
||||
onChange={(e) => setSlot(editing, s.key, e.target.value)}
|
||||
className={inputCls}
|
||||
disabled={locked}
|
||||
className={`${inputCls} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<option value={NO_WIDGET}>None</option>
|
||||
{WIDGETS.map((w) => {
|
||||
@@ -135,6 +146,26 @@ export default function DashboardLayoutSettings({
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* The bottom bar is a fixed Team Chat + Active Users strip; the only control is
|
||||
whether it shows. On the Default it is pinned on (disabled). When off, the row
|
||||
area flexes to fill the page. */}
|
||||
<label className="flex items-center justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0 truncate text-xs text-content-secondary">
|
||||
Show Chat
|
||||
<span className="ml-1 text-content-muted">· Team Chat + Active Users</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modeLayout.slots.barMain !== NO_WIDGET}
|
||||
disabled={locked}
|
||||
onChange={(e) => {
|
||||
setSlot(editing, 'barMain', e.target.checked ? 'team-chat' : NO_WIDGET)
|
||||
setSlot(editing, 'barAside', e.target.checked ? 'active-users' : NO_WIDGET)
|
||||
}}
|
||||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RefreshCw, AlertTriangle, CheckCircle2 } from 'lucide-react'
|
||||
import { useFeedsStore } from '../stores/feedsStore'
|
||||
import { timeAgo } from '../lib/timeAgo'
|
||||
import { MIN_FEED_INTERVAL_MINUTES, hasTimestamp, type FeedCategory } from '../lib/feedTypes'
|
||||
|
||||
// Settings panel for the threat-intel feeds. The master toggle and categories write the same
|
||||
// feeds.json the Dashboard ticker's inline toggle does, so the two stay in lockstep through
|
||||
// the store. Rendered inside the padded Settings content pane (no outer padding here).
|
||||
|
||||
const CATEGORY_LABELS: { id: FeedCategory; label: string; hint: string }[] = [
|
||||
{ id: 'cve', label: 'High-severity CVEs', hint: 'NVD, CVSS ≥ 7.5 — the ticker' },
|
||||
{ id: 'kev', label: 'CISA KEV', hint: 'Known Exploited Vulnerabilities' },
|
||||
{ id: 'headline', label: 'Threat intel', hint: 'CISA, CERT/CC & Bishop Fox advisories & research' },
|
||||
]
|
||||
|
||||
export default function FeedsSettings() {
|
||||
const available = useFeedsStore((s) => s.available)
|
||||
const settings = useFeedsStore((s) => s.settings)
|
||||
const sources = useFeedsStore((s) => s.snapshot.sources)
|
||||
const saving = useFeedsStore((s) => s.saving)
|
||||
const refreshing = useFeedsStore((s) => s.refreshing)
|
||||
const ensureLoaded = useFeedsStore((s) => s.ensureLoaded)
|
||||
const saveSettings = useFeedsStore((s) => s.saveSettings)
|
||||
const refreshNow = useFeedsStore((s) => s.refreshNow)
|
||||
|
||||
const [interval, setIntervalDraft] = useState(settings.intervalMinutes)
|
||||
|
||||
useEffect(() => {
|
||||
void ensureLoaded()
|
||||
}, [ensureLoaded])
|
||||
useEffect(() => {
|
||||
setIntervalDraft(settings.intervalMinutes)
|
||||
}, [settings.intervalMinutes])
|
||||
|
||||
if (!available) {
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-base font-semibold text-content-primary">Threat-Intelligence Feeds</h2>
|
||||
<p className="mt-2 text-sm text-content-muted">
|
||||
Feeds are disabled on this instance (started with <code className="font-mono">--no-feeds</code>,
|
||||
or <code className="font-mono">~/.joro/feeds.json</code> could not be read).
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const commitInterval = () => {
|
||||
const v = Math.max(MIN_FEED_INTERVAL_MINUTES, Math.floor(interval) || MIN_FEED_INTERVAL_MINUTES)
|
||||
setIntervalDraft(v)
|
||||
if (v !== settings.intervalMinutes) void saveSettings({ ...settings, intervalMinutes: v })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-content-primary">Threat-Intelligence Feeds</h2>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
Live high-severity CVEs, the CISA KEV catalog, recent public exploits, and curated
|
||||
advisories on your dashboard. Content is fetched and sanitized server-side; only validated
|
||||
links reach the UI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-content-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={settings.enabled}
|
||||
disabled={saving}
|
||||
onChange={(e) => void saveSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Enable threat-intel feeds
|
||||
<span className="mt-0.5 block text-xs text-content-muted">
|
||||
On by default. Fetches from NVD, CISA, ExploitDB and FIRST.org every{' '}
|
||||
{settings.intervalMinutes} minutes — direct from this machine’s IP (not through your
|
||||
SOCKS proxy unless enabled below).
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-content-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={settings.routeThroughSocks}
|
||||
disabled={saving || !settings.enabled}
|
||||
onChange={(e) => void saveSettings({ ...settings, routeThroughSocks: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Route feed traffic through the SOCKS proxy
|
||||
<span className="mt-0.5 block text-xs text-content-muted">
|
||||
Off by default. Only has an effect when a SOCKS proxy is configured in General settings.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<fieldset disabled={!settings.enabled} className="space-y-2 disabled:opacity-50">
|
||||
<legend className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Sources
|
||||
</legend>
|
||||
{CATEGORY_LABELS.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-sm text-content-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!settings.categories[c.id]}
|
||||
onChange={(e) =>
|
||||
void saveSettings({
|
||||
...settings,
|
||||
categories: { ...settings.categories, [c.id]: e.target.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{c.label}</span>
|
||||
<span className="text-xs text-content-muted">· {c.hint}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-content-primary">
|
||||
Refresh every
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_FEED_INTERVAL_MINUTES}
|
||||
value={interval}
|
||||
disabled={!settings.enabled || saving}
|
||||
onChange={(e) => setIntervalDraft(Number(e.target.value))}
|
||||
onBlur={commitInterval}
|
||||
className="w-20 rounded border border-border bg-surface-input px-2 py-1 text-sm text-content-primary"
|
||||
/>
|
||||
minutes
|
||||
</label>
|
||||
<button
|
||||
onClick={() => void refreshNow()}
|
||||
disabled={!settings.enabled || refreshing}
|
||||
className="inline-flex items-center gap-1.5 rounded bg-accent-secondary px-3 py-1 text-sm font-medium text-black hover:bg-accent-secondary-hover disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />
|
||||
Refresh now
|
||||
</button>
|
||||
</div>
|
||||
<p className="-mt-3 text-xs text-content-muted">
|
||||
Minimum {MIN_FEED_INTERVAL_MINUTES} minutes — NVD rate-limits unauthenticated clients.
|
||||
</p>
|
||||
|
||||
{sources.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Source status
|
||||
</h3>
|
||||
<ul className="mt-2 divide-y divide-border-subtle rounded border border-border">
|
||||
{sources.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<span className="text-content-primary">{s.name}</span>
|
||||
{s.lastError && (
|
||||
<span className="block truncate text-xs text-semantic-error" title={s.lastError}>
|
||||
{s.lastError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 text-xs">
|
||||
{s.stale ? (
|
||||
<span className="inline-flex items-center gap-1 text-semantic-warning">
|
||||
<AlertTriangle size={12} aria-hidden="true" />
|
||||
stale
|
||||
</span>
|
||||
) : hasTimestamp(s.lastSuccess) ? (
|
||||
<span className="inline-flex items-center gap-1 text-semantic-success">
|
||||
<CheckCircle2 size={12} aria-hidden="true" />
|
||||
{timeAgo(s.lastSuccess)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content-muted">not yet fetched</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Radio, AlertTriangle, Settings as SettingsIcon } from 'lucide-react'
|
||||
import { useFeedsStore } from '../../stores/feedsStore'
|
||||
import { EpssBadge } from './feedBadges'
|
||||
import { categoryState } from './feedsShared'
|
||||
import type { FeedItem } from '../../lib/feedTypes'
|
||||
|
||||
// The CVE "stock ticker": a thin, full-width marquee of the latest high-severity CVEs,
|
||||
// docked under the global header on the Dashboard page (not a layout slot). Every field is
|
||||
// server-sanitized, so values render as text nodes and the only link is the derived,
|
||||
// validated NVD URL.
|
||||
//
|
||||
// It doubles as the feature's inline control: when feeds are off it collapses to a slim
|
||||
// "Enable" prompt, satisfying the "toggleable on the Dashboard itself" requirement.
|
||||
|
||||
export default function CveTicker() {
|
||||
const available = useFeedsStore((s) => s.available)
|
||||
const enabled = useFeedsStore((s) => s.settings.enabled)
|
||||
const saving = useFeedsStore((s) => s.saving)
|
||||
const items = useFeedsStore((s) => s.snapshot.items.cve)
|
||||
const sources = useFeedsStore((s) => s.snapshot.sources)
|
||||
const ensureLoaded = useFeedsStore((s) => s.ensureLoaded)
|
||||
const setEnabled = useFeedsStore((s) => s.setEnabled)
|
||||
|
||||
useEffect(() => {
|
||||
void ensureLoaded()
|
||||
}, [ensureLoaded])
|
||||
|
||||
const cveStale = useMemo(
|
||||
() => sources.some((s) => s.category === 'cve' && s.stale),
|
||||
[sources]
|
||||
)
|
||||
const state = categoryState(sources, 'cve', items.length)
|
||||
|
||||
// A slower scroll for a short list; roughly 3.5s of travel per item, floored.
|
||||
const duration = Math.max(24, items.length * 3.5)
|
||||
|
||||
if (!available) return null
|
||||
|
||||
const barBase =
|
||||
'shrink-0 flex items-center gap-2 h-9 px-3 bg-surface-card border-b border-border text-xs select-none'
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className={barBase}>
|
||||
<Radio size={13} className="text-content-muted" aria-hidden="true" />
|
||||
<span className="text-content-secondary">
|
||||
Threat-intel feeds are off.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => void setEnabled(true)}
|
||||
disabled={saving}
|
||||
className="text-accent-secondary hover:text-accent-secondary-hover font-medium disabled:opacity-50"
|
||||
>
|
||||
Enable live CVE, KEV & advisory feeds
|
||||
</button>
|
||||
<Link
|
||||
to="/settings"
|
||||
state={{ category: 'feeds' }}
|
||||
className="ml-auto text-content-muted hover:text-accent-secondary"
|
||||
title="Threat feed settings"
|
||||
>
|
||||
<SettingsIcon size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`joro-ticker ${barBase}`}>
|
||||
<span className="shrink-0 inline-flex items-center gap-1.5 pr-2 border-r border-border-subtle">
|
||||
<Radio size={13} className="text-accent animate-pulse" aria-hidden="true" />
|
||||
<span className="font-semibold uppercase tracking-wide text-[10px] text-content-secondary">
|
||||
Live CVEs
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{state === 'loading' ? (
|
||||
<LoadingMarquee />
|
||||
) : state === 'stale' ? (
|
||||
<span className="text-content-muted">CVE feed unavailable — showing nothing new</span>
|
||||
) : state === 'empty' ? (
|
||||
<span className="text-content-muted">No high-severity CVEs in the last day</span>
|
||||
) : (
|
||||
<div className="joro-ticker-viewport flex-1 overflow-hidden">
|
||||
<div className="joro-ticker-track" style={{ animationDuration: `${duration}s` }}>
|
||||
<TickerRun items={items} />
|
||||
{/* A second copy makes the -50% keyframe loop seamlessly. */}
|
||||
<TickerRun items={items} ariaHidden />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cveStale && items.length > 0 && (
|
||||
<span
|
||||
title="The CVE feed's last refresh failed; showing the last-known set"
|
||||
className="shrink-0 inline-flex items-center gap-1 text-semantic-warning"
|
||||
>
|
||||
<AlertTriangle size={12} aria-hidden="true" />
|
||||
<span className="text-[10px]">stale</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/settings"
|
||||
state={{ category: 'feeds' }}
|
||||
className="shrink-0 text-content-muted hover:text-accent-secondary"
|
||||
title="Threat feed settings"
|
||||
>
|
||||
<SettingsIcon size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// While the CVE feed initializes, scroll a repeating, pulsing "LOADING CVES" marquee. The
|
||||
// pulse lives on the inner text spans so it does not fight the track's scroll animation.
|
||||
function LoadingMarquee() {
|
||||
return (
|
||||
<div className="joro-ticker-viewport flex-1 overflow-hidden">
|
||||
<div className="joro-ticker-track" style={{ animationDuration: '24s' }}>
|
||||
<LoadingRun />
|
||||
<LoadingRun ariaHidden />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingRun({ ariaHidden }: { ariaHidden?: boolean }) {
|
||||
return (
|
||||
<span className="inline-flex animate-pulse items-center" aria-hidden={ariaHidden}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||
<span key={i} className="inline-flex items-center">
|
||||
<span className="font-mono tracking-[0.3em] text-content-muted">LOADING CVES</span>
|
||||
<span className="mx-3 text-border" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TickerRun({ items, ariaHidden }: { items: FeedItem[]; ariaHidden?: boolean }) {
|
||||
return (
|
||||
<span className="inline-flex items-center" aria-hidden={ariaHidden}>
|
||||
{items.map((item, i) => (
|
||||
<span key={`${item.id}-${i}`} className="inline-flex items-center">
|
||||
<TickerCell item={item} />
|
||||
<span className="mx-3 text-border" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TickerCell({ item }: { item: FeedItem }) {
|
||||
const sevClass =
|
||||
item.severity === 'CRITICAL' ? 'text-semantic-error' : 'text-semantic-warning'
|
||||
const body = (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="font-mono text-content-primary">{item.id}</span>
|
||||
{item.vendor && <span className="text-content-secondary capitalize">{item.vendor}</span>}
|
||||
{typeof item.score === 'number' && item.score > 0 && (
|
||||
<span className={`font-semibold ${sevClass}`}>{item.score.toFixed(1)}</span>
|
||||
)}
|
||||
<EpssBadge epss={item.epss} />
|
||||
{item.alsoInKEV && (
|
||||
<span
|
||||
title="In the CISA KEV catalog (actively exploited)"
|
||||
className="text-[9px] font-bold uppercase tracking-wide text-semantic-error"
|
||||
>
|
||||
KEV
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
if (!item.url) return body
|
||||
return (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={item.title || item.id}
|
||||
className="hover:underline"
|
||||
>
|
||||
{body}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Flame, ExternalLink } from 'lucide-react'
|
||||
import DashboardPanel, { EmptyPanelBody } from '../DashboardPanel'
|
||||
import { useFeedsStore } from '../../stores/feedsStore'
|
||||
import { timeAgo } from '../../lib/timeAgo'
|
||||
import { useEnsureFeeds, FeedsOffBody, FeedLoadingBody, categoryState } from './feedsShared'
|
||||
import { EpssBadge } from './feedBadges'
|
||||
import { hasTimestamp, type FeedItem } from '../../lib/feedTypes'
|
||||
|
||||
// CISA Known Exploited Vulnerabilities — what is being exploited in the wild right now. The
|
||||
// most actionable feed for a red-teamer. Push-driven via ws (feeds.updated); seeds once.
|
||||
|
||||
export default function KevWidget() {
|
||||
useEnsureFeeds()
|
||||
const available = useFeedsStore((s) => s.available)
|
||||
const enabled = useFeedsStore((s) => s.settings.enabled)
|
||||
const items = useFeedsStore((s) => s.snapshot.items.kev)
|
||||
const sources = useFeedsStore((s) => s.snapshot.sources)
|
||||
|
||||
const state = categoryState(sources, 'kev', items.length)
|
||||
|
||||
return (
|
||||
<DashboardPanel title="CISA KEV" count={enabled ? items.length : 0}>
|
||||
{!available ? (
|
||||
<EmptyPanelBody>Feeds are unavailable on this instance</EmptyPanelBody>
|
||||
) : !enabled ? (
|
||||
<FeedsOffBody />
|
||||
) : state === 'loading' ? (
|
||||
<FeedLoadingBody />
|
||||
) : state === 'stale' ? (
|
||||
<EmptyPanelBody>KEV feed unavailable — showing nothing new</EmptyPanelBody>
|
||||
) : state === 'empty' ? (
|
||||
<EmptyPanelBody>No known-exploited vulnerabilities loaded yet</EmptyPanelBody>
|
||||
) : (
|
||||
<ul className="divide-y divide-border-subtle">
|
||||
{items.map((item) => (
|
||||
<KevRow key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DashboardPanel>
|
||||
)
|
||||
}
|
||||
|
||||
function KevRow({ item }: { item: FeedItem }) {
|
||||
const vendorProduct = [item.vendor, item.product].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<li className="px-3 py-2 hover:bg-surface-hover">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{vendorProduct && (
|
||||
<span className="truncate text-xs font-semibold capitalize text-content-primary">
|
||||
{vendorProduct}
|
||||
</span>
|
||||
)}
|
||||
{item.ransomware && (
|
||||
<span
|
||||
title="Known ransomware-campaign use"
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded bg-semantic-error-bg px-1 py-0.5 text-[9px] font-bold uppercase text-semantic-error"
|
||||
>
|
||||
<Flame size={9} aria-hidden="true" />
|
||||
Ransomware
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.title && (
|
||||
<div className="truncate text-xs text-content-secondary">{item.title}</div>
|
||||
)}
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 font-mono text-[11px] text-accent-secondary hover:underline"
|
||||
>
|
||||
{item.id}
|
||||
<ExternalLink size={9} aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-content-muted">{item.id}</span>
|
||||
)}
|
||||
<EpssBadge epss={item.epss} />
|
||||
</div>
|
||||
</div>
|
||||
{hasTimestamp(item.published) && (
|
||||
<span className="shrink-0 whitespace-nowrap text-[10px] text-content-muted">
|
||||
{timeAgo(item.published)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { ShieldAlert, ExternalLink } from 'lucide-react'
|
||||
import DashboardPanel, { EmptyPanelBody } from '../DashboardPanel'
|
||||
import { useFeedsStore } from '../../stores/feedsStore'
|
||||
import { useTechStore } from '../../stores/techStore'
|
||||
import { correlateExposure, type ExposureMatch } from '../../lib/feedCorrelate'
|
||||
import { useEnsureFeeds, FeedsOffBody } from './feedsShared'
|
||||
import { EpssBadge } from './feedBadges'
|
||||
|
||||
// Target Exposure: the engagement-specific panel. It cross-references the technologies Joro
|
||||
// fingerprinted on in-scope hosts against the CVE + KEV feeds, surfacing "this target runs
|
||||
// something under active exploitation". Pure client-side join (feedsStore × techStore); both
|
||||
// are push-driven, so this widget only triggers their one-time loads.
|
||||
|
||||
const MAX_ROWS = 60
|
||||
|
||||
export default function TargetExposureWidget() {
|
||||
useEnsureFeeds()
|
||||
const available = useFeedsStore((s) => s.available)
|
||||
const enabled = useFeedsStore((s) => s.settings.enabled)
|
||||
const cve = useFeedsStore((s) => s.snapshot.items.cve)
|
||||
const kev = useFeedsStore((s) => s.snapshot.items.kev)
|
||||
|
||||
const hosts = useTechStore((s) => s.hosts)
|
||||
const techLoaded = useTechStore((s) => s.loaded)
|
||||
const loadTech = useTechStore((s) => s.load)
|
||||
useEffect(() => {
|
||||
if (!techLoaded) void loadTech()
|
||||
}, [techLoaded, loadTech])
|
||||
|
||||
const matches = useMemo(
|
||||
() => correlateExposure(hosts, [...kev, ...cve]).slice(0, MAX_ROWS),
|
||||
[hosts, kev, cve]
|
||||
)
|
||||
|
||||
const body = () => {
|
||||
if (!available) return <EmptyPanelBody>Feeds are unavailable on this instance</EmptyPanelBody>
|
||||
if (!enabled) return <FeedsOffBody />
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<EmptyPanelBody>
|
||||
Capture and fingerprint targets to see which run something under active exploitation
|
||||
</EmptyPanelBody>
|
||||
)
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
return <EmptyPanelBody>No fingerprinted technology matches the active threat feeds</EmptyPanelBody>
|
||||
}
|
||||
return (
|
||||
<ul className="divide-y divide-border-subtle">
|
||||
{matches.map((m) => (
|
||||
<ExposureRow key={`${m.host}|${m.tech.name}|${m.item.id}`} m={m} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPanel
|
||||
title="Target Exposure"
|
||||
count={enabled ? matches.length : 0}
|
||||
headerExtra={
|
||||
<span className="ml-auto float-right text-content-muted" title="Your fingerprinted targets vs active CVE/KEV feeds">
|
||||
<ShieldAlert size={13} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{body()}
|
||||
</DashboardPanel>
|
||||
)
|
||||
}
|
||||
|
||||
function ExposureRow({ m }: { m: ExposureMatch }) {
|
||||
const { host, tech, item } = m
|
||||
const isKev = item.category === 'kev' || item.alsoInKEV
|
||||
const sevClass = item.severity === 'CRITICAL' ? 'text-semantic-error' : 'text-semantic-warning'
|
||||
return (
|
||||
<li className="px-3 py-2 hover:bg-surface-hover">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs text-content-primary">
|
||||
<span className="text-content-secondary">{host}</span>
|
||||
<span className="mx-1 text-border" aria-hidden="true">›</span>
|
||||
<span className="font-medium capitalize">{tech.name}</span>
|
||||
{tech.version && <span className="text-content-muted"> {tech.version}</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 font-mono text-[11px] text-accent-secondary hover:underline"
|
||||
>
|
||||
{item.id}
|
||||
<ExternalLink size={9} aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-content-muted">{item.id}</span>
|
||||
)}
|
||||
{typeof item.score === 'number' && item.score > 0 && (
|
||||
<span className={`text-[11px] font-semibold ${sevClass}`}>{item.score.toFixed(1)}</span>
|
||||
)}
|
||||
{isKev && (
|
||||
<span
|
||||
title="In the CISA KEV catalog (actively exploited)"
|
||||
className="rounded bg-semantic-error-bg px-1 text-[9px] font-bold uppercase text-semantic-error"
|
||||
>
|
||||
KEV
|
||||
</span>
|
||||
)}
|
||||
<EpssBadge epss={item.epss} />
|
||||
{m.confidence === 'possible' && (
|
||||
<span className="text-[9px] uppercase tracking-wide text-content-muted" title="Heuristic name match">
|
||||
possible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import DashboardPanel, { EmptyPanelBody } from '../DashboardPanel'
|
||||
import { useFeedsStore } from '../../stores/feedsStore'
|
||||
import { timeAgo } from '../../lib/timeAgo'
|
||||
import { useEnsureFeeds, FeedsOffBody, FeedLoadingBody, categoryState } from './feedsShared'
|
||||
import { hasTimestamp, type FeedItem } from '../../lib/feedTypes'
|
||||
|
||||
// Curated security-advisory headlines (title + source + link only; all feed HTML is stripped
|
||||
// server-side) from CISA, CERT/CC, and Bishop Fox. A headline with no validated link renders
|
||||
// as plain text. Push-driven.
|
||||
|
||||
export default function ThreatHeadlinesWidget() {
|
||||
useEnsureFeeds()
|
||||
const available = useFeedsStore((s) => s.available)
|
||||
const enabled = useFeedsStore((s) => s.settings.enabled)
|
||||
const items = useFeedsStore((s) => s.snapshot.items.headline)
|
||||
const sources = useFeedsStore((s) => s.snapshot.sources)
|
||||
|
||||
const state = categoryState(sources, 'headline', items.length)
|
||||
|
||||
return (
|
||||
<DashboardPanel title="Threat Intel" count={enabled ? items.length : 0}>
|
||||
{!available ? (
|
||||
<EmptyPanelBody>Feeds are unavailable on this instance</EmptyPanelBody>
|
||||
) : !enabled ? (
|
||||
<FeedsOffBody />
|
||||
) : state === 'loading' ? (
|
||||
<FeedLoadingBody />
|
||||
) : state === 'stale' ? (
|
||||
<EmptyPanelBody>Threat-intel feeds unavailable — showing nothing new</EmptyPanelBody>
|
||||
) : state === 'empty' ? (
|
||||
<EmptyPanelBody>No advisories loaded yet</EmptyPanelBody>
|
||||
) : (
|
||||
<ul className="divide-y divide-border-subtle">
|
||||
{items.map((item) => (
|
||||
<HeadlineRow key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DashboardPanel>
|
||||
)
|
||||
}
|
||||
|
||||
function HeadlineRow({ item }: { item: FeedItem }) {
|
||||
return (
|
||||
<li className="px-3 py-2 hover:bg-surface-hover">
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-start gap-1 text-xs text-content-primary hover:text-accent-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{item.title}</span>
|
||||
<ExternalLink
|
||||
size={11}
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 shrink-0 text-content-muted group-hover:text-accent-secondary"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<span className="block text-xs text-content-primary">{item.title}</span>
|
||||
)}
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-content-muted">
|
||||
<span className="uppercase tracking-wide">{item.source}</span>
|
||||
{hasTimestamp(item.published) && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{timeAgo(item.published)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Small shared badges for the feed widgets.
|
||||
|
||||
// EpssBadge shows the EPSS probability of exploitation as a percentage, tinted when high.
|
||||
// Renders nothing when there is no score.
|
||||
export function EpssBadge({ epss }: { epss?: number }) {
|
||||
if (epss == null || epss <= 0) return null
|
||||
const pct = Math.round(epss * 100)
|
||||
const tint = epss >= 0.5 ? 'text-semantic-warning' : 'text-content-muted'
|
||||
return (
|
||||
<span
|
||||
title={`EPSS: ${pct}% probability of exploitation in the next 30 days`}
|
||||
className={`text-[9px] font-semibold uppercase tracking-wide ${tint}`}
|
||||
>
|
||||
EPSS {pct}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useFeedsStore } from '../../stores/feedsStore'
|
||||
import { hasTimestamp, type FeedCategory, type FeedSourceStatus } from '../../lib/feedTypes'
|
||||
|
||||
// Shared bits for the feed widgets: a one-time load trigger (feeds are push-driven, so this
|
||||
// seeds the store without polling) and the "feeds are off" body with an inline enable button,
|
||||
// so a feed widget on the dashboard is itself a way to turn the feature on.
|
||||
|
||||
// categoryState distinguishes a feed that is still initializing from one that is genuinely
|
||||
// empty or failing, so a panel shows a loading placeholder only before its first fetch lands.
|
||||
// loading — no items yet, no source for this category has succeeded, and none is stale.
|
||||
// stale — no items yet and a source for this category has errored.
|
||||
// empty — a source succeeded but returned nothing (rare for these feeds).
|
||||
// ready — at least one item.
|
||||
export type FeedPanelState = 'loading' | 'stale' | 'empty' | 'ready'
|
||||
|
||||
export function categoryState(
|
||||
sources: FeedSourceStatus[],
|
||||
cat: FeedCategory,
|
||||
count: number
|
||||
): FeedPanelState {
|
||||
if (count > 0) return 'ready'
|
||||
const cs = sources.filter((s) => s.category === cat)
|
||||
if (cs.some((s) => s.stale)) return 'stale'
|
||||
if (cs.length === 0 || cs.every((s) => !hasTimestamp(s.lastSuccess))) return 'loading'
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
export function useEnsureFeeds() {
|
||||
const ensureLoaded = useFeedsStore((s) => s.ensureLoaded)
|
||||
useEffect(() => {
|
||||
void ensureLoaded()
|
||||
}, [ensureLoaded])
|
||||
}
|
||||
|
||||
export function FeedsOffBody() {
|
||||
const saving = useFeedsStore((s) => s.saving)
|
||||
const setEnabled = useFeedsStore((s) => s.setEnabled)
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1.5 px-3 text-center">
|
||||
<span className="text-xs text-content-muted">Threat feeds are off</span>
|
||||
<button
|
||||
onClick={() => void setEnabled(true)}
|
||||
disabled={saving}
|
||||
className="text-xs font-medium text-accent-secondary hover:text-accent-secondary-hover disabled:opacity-50"
|
||||
>
|
||||
Enable feeds
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// FeedLoadingBody is the "the feed is initializing" placeholder: a few pulsing skeleton rows
|
||||
// under a pulsing caption. No fabricated content — just shimmer to show the feed is loading.
|
||||
export function FeedLoadingBody() {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 divide-y divide-border-subtle">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="animate-pulse px-3 py-2" style={{ animationDelay: `${i * 120}ms` }}>
|
||||
<div className="h-3 w-2/3 rounded bg-surface-hover" />
|
||||
<div className="mt-1.5 h-2 w-1/3 rounded bg-surface-hover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="shrink-0 animate-pulse py-2 text-center text-xs text-content-muted">
|
||||
Loading feed…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -339,3 +339,33 @@ div:has(> .cm-editor) {
|
||||
border-color: var(--color-accent-tertiary) !important;
|
||||
transform: scale(1.6);
|
||||
}
|
||||
|
||||
/* CVE ticker marquee.
|
||||
*
|
||||
* The track holds the item run twice; translateX(-50%) scrolls exactly one copy's width,
|
||||
* so the loop is seamless. Duration is set inline per item count. Hover pauses it for
|
||||
* readability, and reduced-motion disables the scroll entirely (the first items stay
|
||||
* visible). */
|
||||
@keyframes joro-ticker {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
.joro-ticker-track {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
animation: joro-ticker linear infinite;
|
||||
}
|
||||
.joro-ticker:hover .joro-ticker-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.joro-ticker-track {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
EchoScanStatus,
|
||||
EchoState,
|
||||
} from './echoTypes'
|
||||
import type { FeedSettings, FeedsResponse } from './feedTypes'
|
||||
import type { InterceptKind, PendingItem } from '../stores/interceptStore'
|
||||
import type { PayloadSource, WordlistCatalog } from './fuzzSources'
|
||||
import type { ChatMessage, ActiveUser } from '../stores/teamStore'
|
||||
@@ -177,6 +178,9 @@ export interface Tech {
|
||||
version?: string
|
||||
categories?: string[]
|
||||
confidence: number
|
||||
/** CPE 2.3 string (vendor:product) when the fingerprint DB has one — used to join a
|
||||
* host's stack against CVE/KEV feeds. */
|
||||
cpe?: string
|
||||
}
|
||||
|
||||
export interface HostTech {
|
||||
@@ -1441,6 +1445,14 @@ export const api = {
|
||||
techScanStatus: () => req<TechScanStatus>('GET', '/tech/scan'),
|
||||
techScanCancel: () => req<TechScanStatus>('POST', '/tech/scan/cancel', {}),
|
||||
|
||||
// Threat-intel feeds (NVD CVEs, CISA KEV, advisory headlines). Off by default; a 404
|
||||
// means --no-feeds or an unreadable feeds.json, which the store reads as "unavailable".
|
||||
getFeeds: () => req<FeedsResponse>('GET', '/feeds'),
|
||||
refreshFeeds: () => req<{ status: string }>('POST', '/feeds/refresh', {}),
|
||||
getFeedsSettings: () => req<FeedSettings>('GET', '/feeds/settings'),
|
||||
setFeedsSettings: (settings: FeedSettings) =>
|
||||
req<FeedSettings>('PUT', '/feeds/settings', settings),
|
||||
|
||||
// Generate
|
||||
generate: (format: string, mode?: string, implantUrl?: string, binaryName?: string, inMemory?: boolean) =>
|
||||
req<{ fileName: string; authKey: string; content: string }>(
|
||||
|
||||
@@ -8,7 +8,7 @@ import { NO_WIDGET } from './dashboardWidgets'
|
||||
// the dashboard frame and is shared by every preset — that's what keeps the
|
||||
// resizer working when the operator swaps chat out of it.
|
||||
|
||||
export type PresetId = 'classic' | 'triptych' | 'grid' | 'stack'
|
||||
export type PresetId = 'intel' | 'classic' | 'triptych' | 'grid' | 'stack'
|
||||
|
||||
export interface SlotDef {
|
||||
key: string
|
||||
@@ -32,6 +32,39 @@ export const BAR_SLOTS: SlotDef[] = [
|
||||
]
|
||||
|
||||
export const PRESETS: PresetDef[] = [
|
||||
{
|
||||
// The curated "Default" dashboard: a freeform, non-customizable intel console. The CVE
|
||||
// ticker rides above the whole page; this is an asymmetric magazine layout — a tall Threat
|
||||
// Intel hero with the operator's own findings/interactions beneath it, beside a single
|
||||
// full-height CISA KEV box. Team Chat + Active Users are pinned to the bottom bar.
|
||||
//
|
||||
// The id stays 'intel' (not 'default') on purpose: renaming it would need a layout-version
|
||||
// migration and could orphan a stored layout. The operator only ever sees the label.
|
||||
// DashboardLayoutSettings locks this preset's slot editors; the arrangement is fixed.
|
||||
id: 'intel',
|
||||
label: 'Default',
|
||||
description: 'The curated intel dashboard (not customizable).',
|
||||
slots: [
|
||||
{ key: 'headlines', label: 'Threat intel' },
|
||||
{ key: 'findings', label: 'Findings' },
|
||||
{ key: 'interactions', label: 'Interactions' },
|
||||
{ key: 'kev', label: 'CISA KEV' },
|
||||
],
|
||||
renderRow: (slot) => (
|
||||
<div className="flex-1 min-h-0 flex gap-2">
|
||||
{/* Left column: the Threat Intel hero over the operator's own session. */}
|
||||
<div className="flex-[3] min-w-0 flex flex-col gap-2">
|
||||
<div className="flex-[2] min-h-0 flex">{slot('headlines')}</div>
|
||||
<div className="flex-[1] min-h-0 flex gap-2">
|
||||
{slot('findings')}
|
||||
{slot('interactions')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right column: a single full-height CISA KEV box. */}
|
||||
<div className="flex-[2] min-w-0 flex">{slot('kev')}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'classic',
|
||||
label: 'Classic',
|
||||
@@ -118,22 +151,32 @@ export interface ModeLayout {
|
||||
slots: SlotMap
|
||||
}
|
||||
|
||||
// Team default: the layout Joro shipped with, unchanged.
|
||||
export const DEFAULT_TEAM_LAYOUT: ModeLayout = {
|
||||
preset: 'classic',
|
||||
// Local default: the curated intel console. Threat Intel leads as the hero over the operator's
|
||||
// own findings/interactions, beside a single CISA KEV box; the CVE ticker rides above the page,
|
||||
// and Team Chat + Active Users are pinned to the bottom bar (always present, both modes).
|
||||
export const DEFAULT_LOCAL_LAYOUT: ModeLayout = {
|
||||
preset: 'intel',
|
||||
slots: {
|
||||
main: 'network-graph',
|
||||
sideTop: 'recent-interactions',
|
||||
sideBottom: 'flagged-requests',
|
||||
headlines: 'threat-headlines',
|
||||
findings: 'detect-findings',
|
||||
interactions: 'recent-interactions',
|
||||
kev: 'kev-feed',
|
||||
barMain: 'team-chat',
|
||||
barAside: 'active-users',
|
||||
},
|
||||
}
|
||||
|
||||
// Local default: what a solo operator running a proxy actually wants. Chat and
|
||||
// the active-user list are dropped (they're a scratchpad and a list of one) but
|
||||
// stay in the catalog, so both bar slots are empty and the bar stays hidden.
|
||||
export const DEFAULT_LOCAL_LAYOUT: ModeLayout = {
|
||||
// Team default: the SAME curated console. The two layouts stay independent — an operator can
|
||||
// still switch either to a different, customizable preset — but both default to the console.
|
||||
export const DEFAULT_TEAM_LAYOUT: ModeLayout = {
|
||||
preset: 'intel',
|
||||
slots: { ...DEFAULT_LOCAL_LAYOUT.slots },
|
||||
}
|
||||
|
||||
// V2_DEFAULT_LOCAL is the local default Joro shipped at layout version 2 (the 2×2 grid). Kept
|
||||
// only so the v2→v3 migration can tell a pristine default — which it upgrades to the intel
|
||||
// console — from a layout the operator arranged, which it leaves alone. See migrateV2.
|
||||
export const V2_DEFAULT_LOCAL: ModeLayout = {
|
||||
preset: 'grid',
|
||||
slots: {
|
||||
topLeft: 'detect-findings',
|
||||
|
||||
@@ -8,6 +8,9 @@ import DetectFindingsWidget from '../components/dashboard/DetectFindingsWidget'
|
||||
import TechnologiesWidget from '../components/dashboard/TechnologiesWidget'
|
||||
import ProxyHealthWidget from '../components/dashboard/ProxyHealthWidget'
|
||||
import AutomationActivityWidget from '../components/dashboard/AutomationActivityWidget'
|
||||
import KevWidget from '../components/dashboard/KevWidget'
|
||||
import ThreatHeadlinesWidget from '../components/dashboard/ThreatHeadlinesWidget'
|
||||
import TargetExposureWidget from '../components/dashboard/TargetExposureWidget'
|
||||
|
||||
// The dashboard widget catalog. Adding a widget means adding a component and
|
||||
// one entry here — nothing in the Go backend, the layout store, or the Settings
|
||||
@@ -23,6 +26,9 @@ export type WidgetId =
|
||||
| 'technologies'
|
||||
| 'proxy-health'
|
||||
| 'automation-activity'
|
||||
| 'kev-feed'
|
||||
| 'threat-headlines'
|
||||
| 'target-exposure'
|
||||
|
||||
/** A slot value of 'none' leaves the slot empty. */
|
||||
export const NO_WIDGET = 'none'
|
||||
@@ -103,6 +109,30 @@ export const WIDGETS: WidgetDef[] = [
|
||||
needs: ['callbacks'],
|
||||
render: () => <RecentInteractionsWidget />,
|
||||
},
|
||||
{
|
||||
id: 'kev-feed',
|
||||
label: 'CISA KEV',
|
||||
description: 'Known Exploited Vulnerabilities — what is being exploited in the wild now.',
|
||||
requiresProxyMode: true,
|
||||
needs: [],
|
||||
render: () => <KevWidget />,
|
||||
},
|
||||
{
|
||||
id: 'threat-headlines',
|
||||
label: 'Threat Intel',
|
||||
description: 'Curated security-advisory headlines, newest first.',
|
||||
requiresProxyMode: true,
|
||||
needs: [],
|
||||
render: () => <ThreatHeadlinesWidget />,
|
||||
},
|
||||
{
|
||||
id: 'target-exposure',
|
||||
label: 'Target Exposure',
|
||||
description: 'Your fingerprinted targets cross-referenced against active CVE/KEV feeds.',
|
||||
requiresProxyMode: true,
|
||||
needs: [],
|
||||
render: () => <TargetExposureWidget />,
|
||||
},
|
||||
{
|
||||
id: 'flagged-requests',
|
||||
label: 'Flagged Requests',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { HostTech, Tech } from './api'
|
||||
import type { FeedItem } from './feedTypes'
|
||||
|
||||
// Correlate the technologies Joro fingerprinted on in-scope hosts against the CVE/KEV feeds,
|
||||
// so the dashboard can answer "which of my targets run something under active exploitation?".
|
||||
//
|
||||
// The precise join is CPE vendor:product — present on the security-relevant subset of the
|
||||
// fingerprint DB (nginx, apache, wordpress, …) and already how Joro derives a feed item's
|
||||
// vendor/product. For a technology with no CPE we fall back to a conservative single-token
|
||||
// name match (length-guarded, product-only) so "WordPress"/"nginx"/"jQuery" still match while
|
||||
// a generic vendor like "apache" cannot drag in every Apache product. Matches carry a
|
||||
// confidence so the UI can label a fallback as "possible".
|
||||
|
||||
export interface ExposureMatch {
|
||||
host: string
|
||||
tech: Tech
|
||||
item: FeedItem
|
||||
confidence: 'exact' | 'possible'
|
||||
}
|
||||
|
||||
// normToken lowercases and strips every non-alphanumeric rune, so "http_server",
|
||||
// "HTTP Server" and "http-server" all compare equal.
|
||||
function normToken(s?: string): string {
|
||||
return (s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '')
|
||||
}
|
||||
|
||||
// cpeVendorProduct parses a CPE 2.3 string into normalized vendor/product, or null.
|
||||
function cpeVendorProduct(cpe?: string): { vendor: string; product: string } | null {
|
||||
if (!cpe) return null
|
||||
const parts = cpe.split(':')
|
||||
if (parts.length < 6 || parts[0] !== 'cpe' || parts[1] !== '2.3') return null
|
||||
const clean = (f: string) => (f === '*' || f === '-' ? '' : f)
|
||||
const product = normToken(clean(parts[4]))
|
||||
if (!product) return null
|
||||
return { vendor: normToken(clean(parts[3])), product }
|
||||
}
|
||||
|
||||
export function correlateExposure(hosts: HostTech[], items: FeedItem[]): ExposureMatch[] {
|
||||
// Index feed items by normalized product.
|
||||
const byProduct = new Map<string, FeedItem[]>()
|
||||
for (const it of items) {
|
||||
const p = normToken(it.product)
|
||||
if (!p) continue
|
||||
const arr = byProduct.get(p)
|
||||
if (arr) arr.push(it)
|
||||
else byProduct.set(p, [it])
|
||||
}
|
||||
|
||||
const matches: ExposureMatch[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (host: string, tech: Tech, item: FeedItem, confidence: 'exact' | 'possible') => {
|
||||
const key = `${host}|${tech.name}|${item.id}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
matches.push({ host, tech, item, confidence })
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
for (const tech of host.technologies) {
|
||||
const cpe = cpeVendorProduct(tech.cpe)
|
||||
if (cpe) {
|
||||
for (const it of byProduct.get(cpe.product) ?? []) {
|
||||
const itVendor = normToken(it.vendor)
|
||||
const vendorOk = !cpe.vendor || !itVendor || itVendor === cpe.vendor
|
||||
add(host.host, tech, it, vendorOk ? 'exact' : 'possible')
|
||||
}
|
||||
} else {
|
||||
// No CPE: a single-token name match on the product only, length-guarded.
|
||||
const name = normToken(tech.name)
|
||||
if (name.length >= 4) {
|
||||
for (const it of byProduct.get(name) ?? []) {
|
||||
add(host.host, tech, it, 'possible')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact first, then by CVSS, then EPSS — the operator's prioritization order.
|
||||
matches.sort((a, b) => {
|
||||
if (a.confidence !== b.confidence) return a.confidence === 'exact' ? -1 : 1
|
||||
const sa = a.item.score ?? 0
|
||||
const sb = b.item.score ?? 0
|
||||
if (sb !== sa) return sb - sa
|
||||
return (b.item.epss ?? 0) - (a.item.epss ?? 0)
|
||||
})
|
||||
return matches
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Threat-intel feed vocabulary and display types. Mirrors internal/feeds' model: every
|
||||
// field is already sanitized server-side (bounded plain text, enum severity, derived/validated
|
||||
// https URL or empty), so the UI renders strings as React text nodes and links as validated
|
||||
// https anchors — never dangerouslySetInnerHTML.
|
||||
|
||||
export type FeedCategory = 'cve' | 'kev' | 'headline'
|
||||
|
||||
export interface FeedItem {
|
||||
category: FeedCategory
|
||||
id: string
|
||||
title: string
|
||||
summary?: string
|
||||
vendor?: string
|
||||
product?: string
|
||||
/** 'HIGH' | 'CRITICAL' only; may be absent. */
|
||||
severity?: string
|
||||
/** CVSS base score, 0–10. */
|
||||
score?: number
|
||||
/** EPSS probability of exploitation in the next 30 days, 0–1. */
|
||||
epss?: number
|
||||
ransomware?: boolean
|
||||
alsoInKEV?: boolean
|
||||
/** Validated https URL, or '' (render the title as plain text, no link). */
|
||||
url?: string
|
||||
source: string
|
||||
published: string
|
||||
}
|
||||
|
||||
export interface FeedSourceStatus {
|
||||
id: string
|
||||
name: string
|
||||
category: FeedCategory
|
||||
lastAttempt?: string
|
||||
lastSuccess?: string
|
||||
lastError?: string
|
||||
itemCount: number
|
||||
stale: boolean
|
||||
}
|
||||
|
||||
export interface FeedSnapshot {
|
||||
// The server always sends all three keys (possibly empty arrays).
|
||||
items: Record<FeedCategory, FeedItem[]>
|
||||
sources: FeedSourceStatus[]
|
||||
generatedAt?: string
|
||||
}
|
||||
|
||||
export interface FeedSettings {
|
||||
enabled: boolean
|
||||
categories: Record<FeedCategory, boolean>
|
||||
intervalMinutes: number
|
||||
/** Route feed egress through the operator's SOCKS proxy (default false = direct). */
|
||||
routeThroughSocks: boolean
|
||||
}
|
||||
|
||||
export interface FeedsResponse {
|
||||
settings: FeedSettings
|
||||
snapshot: FeedSnapshot
|
||||
}
|
||||
|
||||
// The droppable hint pushed on ws `feeds.updated`; the client re-GETs the snapshot.
|
||||
export interface FeedsUpdatedSummary {
|
||||
generatedAt: string
|
||||
counts: Record<string, number>
|
||||
anyStale: boolean
|
||||
}
|
||||
|
||||
export const EMPTY_FEED_SNAPSHOT: FeedSnapshot = {
|
||||
items: { cve: [], kev: [], headline: [] },
|
||||
sources: [],
|
||||
}
|
||||
|
||||
export const DEFAULT_FEED_SETTINGS: FeedSettings = {
|
||||
enabled: true,
|
||||
categories: { cve: true, kev: true, headline: true },
|
||||
intervalMinutes: 15,
|
||||
routeThroughSocks: false,
|
||||
}
|
||||
|
||||
export const MIN_FEED_INTERVAL_MINUTES = 15
|
||||
|
||||
// A never-set Go time.Time serializes as the year-1 sentinel; treat it (and absence) as "no
|
||||
// timestamp" so a widget does not render it as "millennia ago".
|
||||
export function hasTimestamp(ts?: string): ts is string {
|
||||
return !!ts && !ts.startsWith('0001-01-01')
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useManipulateWSStore, type WSFrameEntry } from '../stores/manipulateWSS
|
||||
import { useRequestStore, type RequestSummary } from '../stores/requestStore'
|
||||
import { useTeamStore, type ChatMessage, type ActiveUser } from '../stores/teamStore'
|
||||
import { useTechStore } from '../stores/techStore'
|
||||
import { useFeedsStore } from '../stores/feedsStore'
|
||||
import type { HostTech } from '../lib/api'
|
||||
import { useTeamConnectionStore, type RelayState } from '../stores/teamConnectionStore'
|
||||
import { useTeamFlaggedStore, type FlaggedSummary } from '../stores/teamFlaggedStore'
|
||||
@@ -465,6 +466,11 @@ function handleMessage(msg: WSMessage) {
|
||||
useTechStore.getState().setSummary(msg.data as { hosts: number; techs: number })
|
||||
break
|
||||
}
|
||||
case 'feeds.updated': {
|
||||
// A small summary hint; reload the authoritative snapshot (server-side aggregate).
|
||||
void useFeedsStore.getState().reload()
|
||||
break
|
||||
}
|
||||
case 'detect.scan.started': {
|
||||
const d = msg.data as { jobId: string; kind: string; total: number }
|
||||
useDetectStore.getState().setScan({
|
||||
|
||||
+40
-32
@@ -17,6 +17,7 @@ import {
|
||||
type WidgetDef,
|
||||
} from '../lib/dashboardWidgets'
|
||||
import { presetOrDefault } from '../lib/dashboardPresets'
|
||||
import CveTicker from '../components/dashboard/CveTicker'
|
||||
import { useDashboardPolling } from '../lib/useDashboardPolling'
|
||||
import {
|
||||
useDashboardLayoutStore,
|
||||
@@ -44,7 +45,7 @@ export default function Dashboard({ teamMode = false }: DashboardProps) {
|
||||
const barHeight = dragHeight ?? layout.barHeight
|
||||
|
||||
const modeLayout = teamMode ? layout.team : layout.local
|
||||
const preset = presetOrDefault(modeLayout.preset, teamMode ? 'classic' : 'grid')
|
||||
const preset = presetOrDefault(modeLayout.preset, 'intel')
|
||||
|
||||
const ctx: AvailabilityCtx = useMemo(() => ({ mode, teamMode }), [mode, teamMode])
|
||||
|
||||
@@ -122,39 +123,46 @@ export default function Dashboard({ teamMode = false }: DashboardProps) {
|
||||
const hasRowWidget = preset.slots.some((s) => resolve(s.key) !== null)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-2 gap-2">
|
||||
{hasRowWidget ? (
|
||||
preset.renderRow(rowSlot)
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center">
|
||||
<span className="text-content-muted text-xs">
|
||||
No widgets in this layout -{' '}
|
||||
<Link
|
||||
to="/settings"
|
||||
state={{ category: 'appearance' }}
|
||||
className="text-accent-secondary hover:underline"
|
||||
>
|
||||
configure the dashboard
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col h-full">
|
||||
{/* The CVE ticker is page chrome, docked under the global header — not a layout
|
||||
slot. It gates itself: nothing when feeds are unavailable, an enable prompt when
|
||||
they are off. */}
|
||||
<CveTicker />
|
||||
|
||||
{showBar && (
|
||||
<>
|
||||
<div
|
||||
onPointerDown={handleDragStart}
|
||||
className="shrink-0 h-1.5 touch-none cursor-row-resize rounded-full bg-border hover:bg-accent-secondary transition-colors"
|
||||
/>
|
||||
<div
|
||||
className="shrink-0 flex bg-surface-terminal border border-border rounded"
|
||||
style={{ height: barHeight }}
|
||||
>
|
||||
{barMain}
|
||||
{barAside}
|
||||
<div className="flex flex-col flex-1 min-h-0 p-2 gap-2">
|
||||
{hasRowWidget ? (
|
||||
preset.renderRow(rowSlot)
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center">
|
||||
<span className="text-content-muted text-xs">
|
||||
No widgets in this layout -{' '}
|
||||
<Link
|
||||
to="/settings"
|
||||
state={{ category: 'appearance' }}
|
||||
className="text-accent-secondary hover:underline"
|
||||
>
|
||||
configure the dashboard
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{showBar && (
|
||||
<>
|
||||
<div
|
||||
onPointerDown={handleDragStart}
|
||||
className="shrink-0 h-1.5 touch-none cursor-row-resize rounded-full bg-border hover:bg-accent-secondary transition-colors"
|
||||
/>
|
||||
<div
|
||||
className="shrink-0 flex bg-surface-terminal border border-border rounded"
|
||||
style={{ height: barHeight }}
|
||||
>
|
||||
{barMain}
|
||||
{barAside}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { PluginInfo } from '../lib/api'
|
||||
import ProjectBrowser from '../components/ProjectBrowser'
|
||||
import AutomationSettings from '../components/AutomationSettings'
|
||||
import WebhookSettings from '../components/webhooks/WebhookSettings'
|
||||
import FeedsSettings from '../components/FeedsSettings'
|
||||
import PluginSettings from '../components/PluginSettings'
|
||||
import { Settings, isTeamMode, useSettingsStore } from '../stores/settingsStore'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
@@ -17,7 +18,7 @@ import { useToastStore } from '../stores/toastStore'
|
||||
import { getBrowserPrefs, setBrowserPrefs } from '../lib/browserPrefs'
|
||||
import { NAV } from '../lib/nav'
|
||||
import { useStreamerStore } from '../stores/streamerStore'
|
||||
import { Settings as SettingsIcon, Palette, Folder, AppWindow, Blocks, Bot, Webhook } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Palette, Folder, AppWindow, Blocks, Bot, Webhook, Rss } from 'lucide-react'
|
||||
|
||||
const THEMES = [
|
||||
{ value: 'aomori', label: 'Aomori' },
|
||||
@@ -40,7 +41,7 @@ const THEMES = [
|
||||
{ value: 'tokyo', label: 'Tokyo' },
|
||||
]
|
||||
|
||||
type Category = 'project' | 'general' | 'appearance' | 'testing' | 'plugins' | 'automation' | 'webhooks'
|
||||
type Category = 'project' | 'general' | 'appearance' | 'testing' | 'plugins' | 'automation' | 'webhooks' | 'feeds'
|
||||
|
||||
const CATEGORIES: { id: Category; label: string; icon: ReactNode }[] = [
|
||||
{
|
||||
@@ -78,6 +79,11 @@ const CATEGORIES: { id: Category; label: string; icon: ReactNode }[] = [
|
||||
label: 'Webhooks',
|
||||
icon: <Webhook size={15} strokeWidth={1.7} aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
id: 'feeds',
|
||||
label: 'Threat Feeds',
|
||||
icon: <Rss size={15} strokeWidth={1.7} aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -257,6 +263,9 @@ export default function SettingsPage() {
|
||||
{/* Webhooks likewise, and it is available with no automation flag at all. */}
|
||||
{category === 'webhooks' && <WebhookSettings />}
|
||||
|
||||
{/* Feeds is padded-and-scrolling (it supplies no height of its own). */}
|
||||
{category === 'feeds' && <FeedsSettings />}
|
||||
|
||||
{category === 'plugins' && (
|
||||
<PluginSettings plugins={plugins} onRefresh={refreshPlugins} />
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||
import {
|
||||
DEFAULT_LOCAL_LAYOUT,
|
||||
DEFAULT_TEAM_LAYOUT,
|
||||
V2_DEFAULT_LOCAL,
|
||||
allSlots,
|
||||
presetOrDefault,
|
||||
type ModeLayout,
|
||||
@@ -14,7 +15,7 @@ const STORAGE_KEY = 'joro-dashboard-layout'
|
||||
// The dashboard's bottom-bar height used to live under its own key.
|
||||
const LEGACY_HEIGHT_KEY = 'joro-chat-height'
|
||||
|
||||
export const LAYOUT_VERSION = 2
|
||||
export const LAYOUT_VERSION = 4
|
||||
export const DEFAULT_BAR_HEIGHT = 256
|
||||
export const MIN_BAR_HEIGHT = 100
|
||||
export const MAX_BAR_HEIGHT = 600
|
||||
@@ -93,11 +94,59 @@ function migrateV1(obj: Partial<DashboardLayout>): Partial<DashboardLayout> {
|
||||
if (slots && slots[V1_LOCAL_GRAPH_SLOT] === 'network-graph') {
|
||||
return {
|
||||
...obj,
|
||||
version: LAYOUT_VERSION,
|
||||
version: 2,
|
||||
local: { ...local, slots: { ...slots, [V1_LOCAL_GRAPH_SLOT]: 'automation-activity' } },
|
||||
} as Partial<DashboardLayout>
|
||||
}
|
||||
return { ...obj, version: LAYOUT_VERSION }
|
||||
return { ...obj, version: 2 }
|
||||
}
|
||||
|
||||
// migrateV2 upgrades a version-2 blob to the intel-console local default.
|
||||
//
|
||||
// Only a local layout that is still exactly the shipped v2 grid default (isV2DefaultLocal) is
|
||||
// swapped to the new DEFAULT_LOCAL_LAYOUT; a layout the operator arranged — any other preset,
|
||||
// slot, or widget — is left untouched. Same "only where it still holds the default" discipline
|
||||
// as migrateV1, so a customized dashboard is never stomped; those operators adopt the console
|
||||
// via per-mode Reset. The team layout is unchanged, and a fresh install gets the intel default
|
||||
// straight from defaults().
|
||||
function migrateV2(obj: Partial<DashboardLayout>): Partial<DashboardLayout> {
|
||||
const next = { ...obj, version: 3 } as Partial<DashboardLayout>
|
||||
if (isV2DefaultLocal(obj.local)) {
|
||||
next.local = { ...DEFAULT_LOCAL_LAYOUT, slots: { ...DEFAULT_LOCAL_LAYOUT.slots } }
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// migrateV3 upgrades a version-3 blob to version 4 by resetting BOTH layouts to the current
|
||||
// Default.
|
||||
//
|
||||
// Unlike migrateV1/V2 (which touch only a pristine default), this is a deliberate, one-time
|
||||
// full reset: the Default changed substantially (Threat Intel hero, a single CISA KEV box, and
|
||||
// Team Chat + Active Users pinned to the bottom bar), and every operator should land on it. A
|
||||
// plain sanitize would otherwise keep a stored `barMain: 'none'` and the old slots. The
|
||||
// customizable presets remain, so an operator can rebuild a prior arrangement afterward; the
|
||||
// saved bar height is preserved.
|
||||
function migrateV3(obj: Partial<DashboardLayout>): Partial<DashboardLayout> {
|
||||
return {
|
||||
...obj,
|
||||
version: LAYOUT_VERSION,
|
||||
local: { ...DEFAULT_LOCAL_LAYOUT, slots: { ...DEFAULT_LOCAL_LAYOUT.slots } },
|
||||
team: { ...DEFAULT_TEAM_LAYOUT, slots: { ...DEFAULT_TEAM_LAYOUT.slots } },
|
||||
}
|
||||
}
|
||||
|
||||
// isV2DefaultLocal reports whether a stored local layout is the pristine v2 grid default: the
|
||||
// grid preset with its four default widgets and an empty bottom bar. Bar slots are treated as
|
||||
// empty whether stored as 'none' or absent.
|
||||
function isV2DefaultLocal(local: unknown): boolean {
|
||||
const l = local as { preset?: unknown; slots?: Record<string, unknown> } | undefined
|
||||
if (!l || l.preset !== V2_DEFAULT_LOCAL.preset || !l.slots) return false
|
||||
const want = V2_DEFAULT_LOCAL.slots
|
||||
for (const k of ['topLeft', 'topRight', 'bottomLeft', 'bottomRight']) {
|
||||
if (l.slots[k] !== want[k]) return false
|
||||
}
|
||||
const barEmpty = (k: string) => !l.slots![k] || l.slots![k] === NO_WIDGET
|
||||
return barEmpty('barMain') && barEmpty('barAside')
|
||||
}
|
||||
|
||||
// sanitize is the single gate for untrusted layout data, used by both the
|
||||
@@ -110,6 +159,8 @@ export function sanitize(raw: unknown): DashboardLayout {
|
||||
if (!raw || typeof raw !== 'object') return defaults()
|
||||
let obj = raw as Partial<DashboardLayout>
|
||||
if (obj.version === 1) obj = migrateV1(obj)
|
||||
if (obj.version === 2) obj = migrateV2(obj)
|
||||
if (obj.version === 3) obj = migrateV3(obj)
|
||||
if (obj.version !== LAYOUT_VERSION) return defaults()
|
||||
return {
|
||||
version: LAYOUT_VERSION,
|
||||
@@ -179,6 +230,11 @@ interface DashboardLayoutState {
|
||||
// changePreset carries widget choices across a preset change positionally, so
|
||||
// trying a preset and switching back doesn't wipe the operator's picks.
|
||||
function changePreset(current: ModeLayout, next: PresetId, mode: LayoutMode): ModeLayout {
|
||||
// The 'intel' (Default) layout is a fixed, non-customizable arrangement — switching to it
|
||||
// restores the canonical widgets rather than carrying slots over positionally.
|
||||
if (next === 'intel') {
|
||||
return { preset: 'intel', slots: { ...DEFAULT_LOCAL_LAYOUT.slots } }
|
||||
}
|
||||
const from = presetOrDefault(current.preset, defaultFor(mode).preset)
|
||||
const to = presetOrDefault(next, defaultFor(mode).preset)
|
||||
const carried = from.slots.map((s) => current.slots[s.key]).filter((v): v is string => !!v)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { create } from 'zustand'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import {
|
||||
DEFAULT_FEED_SETTINGS,
|
||||
EMPTY_FEED_SNAPSHOT,
|
||||
type FeedSettings,
|
||||
type FeedSnapshot,
|
||||
} from '../lib/feedTypes'
|
||||
|
||||
// Threat-intel feed state. Push-driven, like techStore: it loads once and ws.ts folds in
|
||||
// `feeds.updated` by re-fetching the authoritative snapshot (a map row is a server-side
|
||||
// aggregate, so the client reloads rather than merging). It never joins the 5s dashboard
|
||||
// poll loop — feeds refresh every 15 min server-side.
|
||||
//
|
||||
// `available` is false when the instance returns 404 (started with --no-feeds, or feeds.json
|
||||
// could not be read): the ticker and feed widgets then render nothing rather than an error.
|
||||
|
||||
interface FeedsState {
|
||||
available: boolean
|
||||
loaded: boolean
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
refreshing: boolean
|
||||
settings: FeedSettings
|
||||
snapshot: FeedSnapshot
|
||||
|
||||
/** Loads settings + snapshot once (no-op if already loaded/loading). */
|
||||
ensureLoaded: () => Promise<void>
|
||||
/** Re-fetches the authoritative snapshot + settings (on ws feeds.updated). */
|
||||
reload: () => Promise<void>
|
||||
setEnabled: (enabled: boolean) => Promise<void>
|
||||
saveSettings: (settings: FeedSettings) => Promise<void>
|
||||
refreshNow: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useFeedsStore = create<FeedsState>((set, get) => ({
|
||||
available: true,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
saving: false,
|
||||
refreshing: false,
|
||||
settings: DEFAULT_FEED_SETTINGS,
|
||||
snapshot: EMPTY_FEED_SNAPSHOT,
|
||||
|
||||
ensureLoaded: async () => {
|
||||
const st = get()
|
||||
if (st.loaded || st.loading) return
|
||||
set({ loading: true })
|
||||
try {
|
||||
const res = await api.getFeeds()
|
||||
set({ available: true, settings: res.settings, snapshot: res.snapshot, loaded: true })
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
set({ available: false, loaded: true })
|
||||
} else {
|
||||
// Transient failure (e.g. outside proxy mode): leave defaults, mark loaded so
|
||||
// the UI stops showing a spinner. A ws event or a manual retry can refill it.
|
||||
set({ loaded: true })
|
||||
}
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
reload: async () => {
|
||||
if (!get().available) return
|
||||
try {
|
||||
const res = await api.getFeeds()
|
||||
set({ settings: res.settings, snapshot: res.snapshot })
|
||||
} catch {
|
||||
// keep prior state on a transient failure
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled: async (enabled) => {
|
||||
await get().saveSettings({ ...get().settings, enabled })
|
||||
},
|
||||
|
||||
saveSettings: async (settings) => {
|
||||
const prev = get().settings
|
||||
set({ saving: true, settings }) // optimistic
|
||||
try {
|
||||
const saved = await api.setFeedsSettings(settings)
|
||||
set({ settings: saved })
|
||||
} catch {
|
||||
set({ settings: prev }) // roll back
|
||||
} finally {
|
||||
set({ saving: false })
|
||||
}
|
||||
},
|
||||
|
||||
refreshNow: async () => {
|
||||
if (get().refreshing) return
|
||||
set({ refreshing: true })
|
||||
try {
|
||||
await api.refreshFeeds()
|
||||
// The server broadcasts feeds.updated when the refresh completes; ws.ts reloads.
|
||||
} catch {
|
||||
// ignore (debounced or disabled)
|
||||
} finally {
|
||||
set({ refreshing: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
Reference in new issue
Block a user