Updated default dashboard layout to add various vulnerability/threat intelligence feeds

This commit is contained in:
Tony West committed 2026-10-14 01:51:46 -04:00
1 parent 8dab71c673
commit ab06f4dbdb
36 files changed
+2969 -56

No files matched your search

+163
View File
@@ -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}
}
+103
View File
@@ -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"})
}
+7
View File
@@ -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)
}
+14
View File
@@ -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).
+6
View File
@@ -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.
//
+198
View File
@@ -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
}
+134
View File
@@ -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
}
+54
View File
@@ -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
}
+76
View File
@@ -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"`
}
+250
View File
@@ -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 ""
}
+255
View File
@@ -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 ""
}
+80
View File
@@ -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)
},
}
}
+152
View File
@@ -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)),
}
}
+148
View File
@@ -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)
}
+4 -1
View File
@@ -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
+11 -3
View File
@@ -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)
}
}
+1
View File
@@ -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
+35 -4
View File
@@ -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&rsquo;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>
)
+186
View File
@@ -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&rsquo;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>
)
}
+193
View File
@@ -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 &amp; 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>
)
}
+30
View File
@@ -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;
}
}
+12
View File
@@ -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 }>(
+54 -11
View File
@@ -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',
+30
View File
@@ -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',
+88
View File
@@ -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
}
+85
View File
@@ -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')
}
+6
View File
@@ -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
View File
@@ -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>
)
}
+11 -2
View File
@@ -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} />
)}
+59 -3
View File
@@ -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)
+104
View File
@@ -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 })
}
},
}))