Compare commits

..
18 Commits
Author SHA1 Message Date
Tony West 376880c88d Increment version string 2026-10-30 19:04:26 -04:00
Tony West f7097f22d1 disable MPTCP by default to harden network listeners 2026-10-30 19:04:17 -04:00
Tony West bbe68abe7c Incremented version string 2026-10-29 21:08:03 -04:00
Tony West ee6083e311 fix to remove hosts from history once added to noise filter 2026-10-29 21:07:24 -04:00
Tony West 0f7b59fd0b Incremented version string 2026-10-28 20:51:50 -04:00
Tony West 78945ed001 Added context menu item to exclude hosts from scope 2026-10-28 20:51:13 -04:00
Tony West 647aebdba6 Fix incorrect error message when attempting to load API spec from a URL that does not host one 2026-10-28 20:21:25 -04:00
Tony West 4b3c4766e2 Fix for echo tab to improve UX 2026-10-28 19:59:17 -04:00
Tony West e6a9f1f348 Added missing context menu items to history table rows and site map 2026-10-28 19:04:51 -04:00
Tony West 01ba289b5e Fix missing error message on failed API spec load 2026-10-28 18:47:27 -04:00
Tony West 762c85454c Incremented version string 2026-10-14 02:59:14 -04:00
Tony West be27327a0d Update Tailwind to v4 2026-10-14 02:59:05 -04:00
Tony West f7889a0e1d Minor formatting adjustments 2026-10-14 02:43:56 -04:00
Tony West 5cdb71ac24 Enabled individual enable/disable toggle for custom request data items 2026-10-14 02:30:01 -04:00
Tony West 976e518298 Updated documentation 2026-10-14 02:12:33 -04:00
Tony West f80333451e Updated example image 2026-10-14 02:11:07 -04:00
Tony West bc446b1d64 Fixed various text size and alignment issues 2026-10-14 01:58:42 -04:00
Tony West ab06f4dbdb Updated default dashboard layout to add various vulnerability/threat intelligence feeds 2026-10-14 01:51:46 -04:00
85 changed files with 4710 additions and 1546 deletions

No files matched your search

+2 -3
View File
@@ -1,12 +1,11 @@
# Joro
![](assets/header.png)
A web exploitation framework for offensive security professionals. Intercepting proxy, blind vulnerability detection, web shell generation, C2 integration, and collaboration tools in a single binary with an embedded web UI.
![](examples/images/joro.png)
> Warning: This tool is intended for authorized penetration testing and security research only. You must only use Joro against systems you own or have explicit written permission to test. Unauthorized access to computer systems is illegal. Bishop Fox assumes no liability and is not responsible for any misuse or damage caused by this tool. Use responsibly.
![](examples/images/joro-history.png)
## Features
Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

+2
View File
@@ -2,6 +2,8 @@ module github.com/BishopFox/joro
go 1.25.0
godebug multipathtcp=0
require github.com/hashicorp/go-uuid v1.0.3
require (
+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}
}
+12 -6
View File
@@ -66,12 +66,18 @@ type projectReplaceRule struct {
MatchType string `json:"matchType"`
Match string `json:"match"`
Replace string `json:"replace"`
// Disabled persists the per-rule opt-out as a disabled flag (absent = enabled),
// so an existing .joro written before this field loads every rule enabled with no
// version bump or normalizeProjectConfig backfill.
Disabled bool `json:"disabled,omitempty"`
}
type projectCustomItem struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
// Disabled: see projectReplaceRule.Disabled.
Disabled bool `json:"disabled,omitempty"`
}
type projectNote struct {
@@ -320,13 +326,13 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
replaceRules := s.replace.Rules()
pReplaceRules := make([]projectReplaceRule, len(replaceRules))
for i, r := range replaceRules {
pReplaceRules[i] = projectReplaceRule{Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace}
pReplaceRules[i] = projectReplaceRule{Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Disabled: !r.Enabled}
}
customItems := s.customData.Items()
pCustomItems := make([]projectCustomItem, len(customItems))
for i, item := range customItems {
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value}
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value, Disabled: !item.Enabled}
}
var pNotes []projectNote
@@ -900,14 +906,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
replaceRules := make([]proxy.MatchReplaceRule, len(cfg.ReplaceRules))
for i, r := range cfg.ReplaceRules {
replaceRules[i] = proxy.MatchReplaceRule{ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace}
replaceRules[i] = proxy.MatchReplaceRule{ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Enabled: !r.Disabled}
}
s.replace.SetEnabled(cfg.ReplaceEnabled)
s.replace.SetRules(replaceRules)
customItems := make([]proxy.CustomAddition, len(cfg.CustomDataItems))
for i, item := range cfg.CustomDataItems {
customItems[i] = proxy.CustomAddition{ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value}
customItems[i] = proxy.CustomAddition{ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value, Enabled: !item.Disabled}
}
s.customData.SetEnabled(cfg.CustomDataEnabled)
s.customData.SetItems(customItems)
@@ -1664,7 +1670,7 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
continue
}
replaceRules = append(replaceRules, proxy.MatchReplaceRule{
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace,
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Enabled: !r.Disabled,
})
}
s.replace.SetRules(replaceRules)
@@ -1680,7 +1686,7 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
continue
}
customItems = append(customItems, proxy.CustomAddition{
ID: proxy.GenerateID(), Type: it.Type, Name: it.Name, Value: it.Value,
ID: proxy.GenerateID(), Type: it.Type, Name: it.Name, Value: it.Value, Enabled: !it.Disabled,
})
}
s.customData.SetItems(customItems)
+16
View File
@@ -56,6 +56,22 @@ func (s *APIServer) handleAddCustomDataItem(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusCreated, item)
}
func (s *APIServer) handleSetCustomDataItemEnabled(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if !s.customData.SetItemEnabled(id, body.Enabled) {
writeError(w, http.StatusNotFound, "item not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
}
func (s *APIServer) handleDeleteCustomDataItem(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.customData.RemoveItem(id) {
+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"})
}
+16
View File
@@ -60,6 +60,22 @@ func (s *APIServer) handleAddReplaceRule(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusCreated, rule)
}
func (s *APIServer) handleSetReplaceRuleEnabled(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if !s.replace.SetRuleEnabled(id, body.Enabled) {
writeError(w, http.StatusNotFound, "rule not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
}
func (s *APIServer) handleDeleteReplaceRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.replace.RemoveRule(id) {
+15
View File
@@ -158,3 +158,18 @@ func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request)
s.clearDetectFindingsWithHistory()
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
// handleDeleteRequestsByHost removes captured requests whose host matches a glob,
// the "clear what is already captured" companion to adding a noise pattern. The
// required "pattern" query param is the host glob (filepath.Match), the same value
// stored as the noise pattern. Removing the requests also drops them from the site
// map, which is derived from the same store.
func (s *APIServer) handleDeleteRequestsByHost(w http.ResponseWriter, r *http.Request) {
pattern := r.URL.Query().Get("pattern")
if pattern == "" {
writeError(w, http.StatusBadRequest, "pattern is required")
return
}
removed := s.store.DeleteByHostPattern(pattern)
writeJSON(w, http.StatusOK, map[string]int{"deleted": removed})
}
+35 -14
View File
@@ -140,11 +140,13 @@ func (s *APIServer) fetchSpecViaProxy(r *http.Request, rawURL, userAgent string)
// notADocument builds the hint returned when a URL served something that is not
// a description document.
//
// The most common case by far is a Swagger UI page: Joro's own detect rule
// flags those under the same finding as a real document, because its pattern
// matches the string "swagger-ui". Answering "that is the UI, and here is where
// it usually loads its document from" turns the most common failure into the
// next action rather than a dead end.
// When the page is an API-documentation UI (Swagger UI and its siblings all
// embed or name the document they render), the hint names it and offers the
// references it loads from — the most common failure becomes the next action.
// A plain HTML page names nothing, so following its own scripts would list the
// site's analytics and bundles as if they were documents; it is offered the
// well-known document paths to sweep instead. The two are told apart by the
// bytes, not assumed from "this is HTML", so neither verdict overclaims.
func notADocument(body []byte, contentType, sourceURL string) map[string]any {
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") ||
apispec.LooksLikeHTMLDocument(body)
@@ -152,24 +154,43 @@ func notADocument(body []byte, contentType, sourceURL string) map[string]any {
return nil
}
candidates := apispec.ExtractSpecRefs(body, sourceURL)
if len(candidates) == 0 {
for _, p := range apispec.CandidatePaths("", apispec.CandidatePriority) {
candidates = append(candidates, p)
if len(candidates) >= 12 {
break
}
if ui := apispec.DetectDocUI(body); ui != "" {
candidates := apispec.ExtractSpecRefs(body, sourceURL)
if len(candidates) == 0 {
candidates = priorityCandidates()
}
return map[string]any{
"error": fmt.Sprintf("that URL served the %s page, not an API description document — these are the paths it loads from", ui),
"kind": "html",
"ui": ui,
"contentType": contentType,
"sourceUrl": sourceURL,
"candidates": candidates,
}
}
return map[string]any{
"error": "that URL served an HTML page, not an API description document",
"error": "that URL served an HTML page, not an API description document — try these well-known document paths on this host",
"kind": "html",
"ui": "",
"contentType": contentType,
"sourceUrl": sourceURL,
"candidates": candidates,
"candidates": priorityCandidates(),
}
}
// priorityCandidates is the head of the standard discovery wordlist, offered as
// paths to try when a page names no document of its own.
func priorityCandidates() []string {
var out []string
for _, p := range apispec.CandidatePaths("", apispec.CandidatePriority) {
out = append(out, p)
if len(out) >= 12 {
break
}
}
return out
}
func (s *APIServer) handleSpecList(w http.ResponseWriter, r *http.Request) {
type summary struct {
ID string `json:"id"`
+10
View File
@@ -90,6 +90,7 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/requests/{id}", s.handleGetRequest)
mux.HandleFunc("GET /api/v1/requests/{id}/params", s.handleGetRequestParams)
mux.HandleFunc("DELETE /api/v1/requests", s.handleClearRequests)
mux.HandleFunc("DELETE /api/v1/requests/by-host", s.handleDeleteRequestsByHost)
// Intercept
mux.HandleFunc("GET /api/v1/intercept", s.handleGetInterceptQueue)
@@ -266,12 +267,14 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/replace", s.handleGetReplace)
mux.HandleFunc("PUT /api/v1/replace/enabled", s.handleSetReplaceEnabled)
mux.HandleFunc("POST /api/v1/replace/rules", s.handleAddReplaceRule)
mux.HandleFunc("PUT /api/v1/replace/rules/{id}/enabled", s.handleSetReplaceRuleEnabled)
mux.HandleFunc("DELETE /api/v1/replace/rules/{id}", s.handleDeleteReplaceRule)
// Custom Data
mux.HandleFunc("GET /api/v1/customdata", s.handleGetCustomData)
mux.HandleFunc("PUT /api/v1/customdata/enabled", s.handleSetCustomDataEnabled)
mux.HandleFunc("POST /api/v1/customdata/items", s.handleAddCustomDataItem)
mux.HandleFunc("PUT /api/v1/customdata/items/{id}/enabled", s.handleSetCustomDataItemEnabled)
mux.HandleFunc("DELETE /api/v1/customdata/items/{id}", s.handleDeleteCustomDataItem)
// Config save/load
@@ -425,6 +428,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).
+42
View File
@@ -148,6 +148,48 @@ func LooksLikeHTMLDocument(body []byte) bool {
strings.Contains(lower, "<head")
}
// docUIMarkers name the API-documentation UI frameworks that embed or reference
// the document they render, each by strings unambiguous enough that an ordinary
// web page never carries them. Order is match priority; the Swagger entry leads
// because it is far and away the common case. The Swagger bundle names overlap
// uiBundleNames on purpose — the same scripts that are skipped when hunting for
// an embedded document are what positively identify the page as Swagger UI.
var docUIMarkers = []struct {
label string
markers []string
}{
{"Swagger UI", []string{
"swagger-ui-bundle", "swagger-ui-standalone-preset", "swagger-ui.css",
"swaggeruibundle", `id="swagger-ui"`, "id='swagger-ui'",
"swagger-ui-init", "<title>swagger ui",
}},
{"ReDoc", []string{"redoc.standalone", "<redoc", "redoc.init("}},
{"RapiDoc", []string{"<rapi-doc", "rapidoc-min.js"}},
{"Stoplight Elements", []string{"<elements-api", "@stoplight/elements"}},
{"Scalar", []string{"scalar.min.js", "@scalar/api-reference"}},
}
// DetectDocUI reports which API-documentation UI framework an HTML page is, or
// "" if it is just an HTML page. A doc UI embeds or names the document it loads,
// so naming it turns the most common load failure into the next action; a plain
// page must not be mislabelled as one.
func DetectDocUI(body []byte) string {
// Bound the scan: these markers sit in the page head, near the top.
window := body
if len(window) > 64<<10 {
window = window[:64<<10]
}
lower := strings.ToLower(string(window))
for _, ui := range docUIMarkers {
for _, m := range ui.markers {
if strings.Contains(lower, m) {
return ui.label
}
}
}
return ""
}
var (
// specKeyRe matches a "url:" / "configUrl:" / "specUrl:" assignment, which is
// how a Swagger UI page names the document it loads.
+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.
//
+9 -3
View File
@@ -66,7 +66,7 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
seen := map[[2]int]bool{}
capped := false
record := func(v view, idx int32, start, end int, base int, coord string, part string, spans []ctxSpan) bool {
record := func(buf []byte, v view, idx int32, start, end int, base int, coord string, part string, spans []ctxSpan) bool {
if len(rep.Reflections) >= cfg.MaxReflectionsPerRequest {
capped = true
return false
@@ -83,6 +83,10 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
s = sinkAt(spans, srcStart)
}
sur := survivedChars(v, start, end)
// The snippet is cut from the buffer the offsets index, so it is correct
// regardless of coord: gzip bodies share no coordinates with the raw
// document, which is why the window cannot be reconstructed client-side.
snip, match := buildSnippet(buf, srcStart, srcEnd)
val := ac.needles[idx]
for _, vi := range index[val] {
src := values[vi]
@@ -95,6 +99,8 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
Survived: sur,
Breakout: breakout(s, sur),
Confidence: confidence(val),
RequestID: rep.RequestID, URL: rep.URL,
Snippet: snip, SnippetMatch: match,
})
}
return true
@@ -105,7 +111,7 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
if len(m.RespRawHdr) > 0 {
for _, v := range buildViews(m.RespRawHdr, cfg) {
ac.find(v.b, func(idx int32, s, e int) bool {
return record(v, idx, s, e, 0, "raw", "response", nil)
return record(m.RespRawHdr, v, idx, s, e, 0, "raw", "response", nil)
})
if capped {
break
@@ -124,7 +130,7 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
}
for _, v := range buildViews(m.RespBody, cfg) {
ac.find(v.b, func(idx int32, s, e int) bool {
return record(v, idx, s, e, base, coord, "response", spans)
return record(m.RespBody, v, idx, s, e, base, coord, "response", spans)
})
if capped {
break
+14
View File
@@ -178,6 +178,20 @@ type Reflection struct {
// Breakout reports that Survived is enough to leave Context.
Breakout bool `json:"breakout"`
Confidence Confidence `json:"confidence"`
// RequestID and URL name the specific message this occurrence came from. An
// Entry aggregates examples across requests, so an example's Span only makes
// sense against its own response: these carry that response's identity.
RequestID string `json:"requestId,omitempty"`
URL string `json:"url,omitempty"`
// Snippet is a sanitized window of the response bytes around the match, and
// SnippetMatch locates the matched run within Snippet (offsets into Snippet,
// not the document). It lets the UI show the value in situ without refetching
// and re-decoding the raw body, which a compressed response makes impossible
// client-side. Bounded by snippetMaxBytes: a Reflection rides in capped slices
// but many of them, so the byte cap is the budget, not conditional population.
Snippet string `json:"snippet,omitempty"`
SnippetMatch Span `json:"snippetMatch"`
}
// SinkKey identifies a distinct place a parameter lands, for the roll-up.
+102
View File
@@ -0,0 +1,102 @@
package echo
import (
"bytes"
"strings"
)
// Snippet bounds. A Reflection carries its snippet in capped slices but many of
// them, so the window is the budget: snippetPad bytes of context on each side,
// the whole window clamped to snippetMaxBytes. snippetMaxBytes mirrors detect's
// maxEvidenceBytes so the two evidence surfaces stay the same size.
const (
snippetPad = 80
snippetMaxBytes = 256
)
// snippetWindow returns the raw byte range [lo, hi) of src to cut a snippet
// from, given the matched run [start, end). It pads each side, then expands
// outward to whole lines so the match reads with a line of context above and
// below, and clamps the window to snippetMaxBytes with the match kept. lo <=
// start and hi >= end always, except a match longer than the cap, whose head
// alone is returned.
func snippetWindow(src []byte, start, end int) (lo, hi int) {
n := len(src)
if start < 0 {
start = 0
}
if end > n {
end = n
}
if end < start {
end = start
}
if end-start >= snippetMaxBytes {
hi = min(start+snippetMaxBytes, n)
return start, hi
}
lo0 := max(start-snippetPad, 0)
hi0 := min(end+snippetPad, n)
// Expand to the line lo0 falls in and the line hi0 falls in, so a context
// line survives on each side rather than collapsing to the match's own line.
lo = 0
if nl := bytes.LastIndexByte(src[:lo0], '\n'); nl >= 0 {
lo = nl + 1
}
hi = n
if nl := bytes.IndexByte(src[hi0:], '\n'); nl >= 0 {
hi = hi0 + nl
}
// Clamp to the byte cap, trimming context toward the match (a single line
// longer than the budget is cut mid-way; the viewer wraps it).
avail := snippetMaxBytes - (end - start)
if start-lo > avail/2 {
lo = start - avail/2
}
if hi-end > avail-avail/2 {
hi = end + (avail - avail/2)
}
return lo, hi
}
// sanitizeSnippet renders a response slice for display. It keeps newlines and
// tabs so the window reads as the two or three lines around the match, drops
// lone carriage returns (the newline alone breaks the line), and escapes other
// control bytes to \xNN. Unlike detect's sanitizeEvidence it never truncates:
// the three parts of a snippet are sanitized separately so the match offsets
// stay exact, and snippetWindow has already bounded the raw input.
func sanitizeSnippet(b []byte) string {
var sb strings.Builder
sb.Grow(len(b))
for _, r := range string(b) {
switch {
case r == '\n' || r == '\t':
sb.WriteRune(r)
case r == '\r':
// Dropped: a CRLF still breaks at the newline.
case r < 0x20 || r == 0x7f:
const hexDigits = "0123456789abcdef"
sb.WriteString("\\x")
sb.WriteByte(hexDigits[(r>>4)&0xf])
sb.WriteByte(hexDigits[r&0xf])
default:
sb.WriteRune(r)
}
}
return sb.String()
}
// buildSnippet cuts a display snippet from src around the matched run
// [start, end) and returns it with the match located inside it. The match may
// be clamped when it is longer than the cap; the caller's stored Span is
// unaffected because that indexes the document, not the snippet.
func buildSnippet(src []byte, start, end int) (string, Span) {
lo, hi := snippetWindow(src, start, end)
mEnd := min(end, hi)
pre := sanitizeSnippet(src[lo:start])
mid := sanitizeSnippet(src[start:mEnd])
suf := sanitizeSnippet(src[mEnd:hi])
return pre + mid + suf, Span{Start: len(pre), End: len(pre) + len(mid)}
}
+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)
}
+26 -8
View File
@@ -11,10 +11,11 @@ import (
// CustomAddition defines a single piece of data to inject into requests.
type CustomAddition struct {
ID string `json:"id"`
Type string `json:"type"` // "header", "query", "body"
Name string `json:"name"`
Value string `json:"value"`
ID string `json:"id"`
Type string `json:"type"` // "header", "query", "body"
Name string `json:"name"`
Value string `json:"value"`
Enabled bool `json:"enabled"` // per-item gate, under the global toggle
}
// CustomData manages a set of additive request modifications.
@@ -62,10 +63,11 @@ func (cd *CustomData) SetItems(items []CustomAddition) {
// AddItem adds a new item with a generated ID. Returns the item.
func (cd *CustomData) AddItem(typ, name, value string) CustomAddition {
item := CustomAddition{
ID: GenerateID(),
Type: typ,
Name: name,
Value: value,
ID: GenerateID(),
Type: typ,
Name: name,
Value: value,
Enabled: true,
}
cd.mu.Lock()
defer cd.mu.Unlock()
@@ -86,6 +88,19 @@ func (cd *CustomData) RemoveItem(id string) bool {
return false
}
// SetItemEnabled toggles a single item by ID. Returns true if found.
func (cd *CustomData) SetItemEnabled(id string, enabled bool) bool {
cd.mu.Lock()
defer cd.mu.Unlock()
for i := range cd.items {
if cd.items[i].ID == id {
cd.items[i].Enabled = enabled
return true
}
}
return false
}
// applyCustomData applies custom data additions to an *http.Request.
func applyCustomData(cd *CustomData, r *http.Request) *http.Request {
if cd == nil || !cd.IsEnabled() {
@@ -98,6 +113,9 @@ func applyCustomData(cd *CustomData, r *http.Request) *http.Request {
cd.mu.RUnlock()
for _, item := range items {
if !item.Enabled {
continue
}
switch item.Type {
case "header":
if !slices.Contains(r.Header.Values(item.Name), item.Value) {
+3
View File
@@ -345,6 +345,9 @@ func applyCustomDataRaw(cd *CustomData, raw []byte) []byte {
bodyMutated := false
for _, item := range items {
if !item.Enabled {
continue
}
switch item.Type {
case "header":
full := fmt.Sprintf("%s: %s", item.Name, item.Value)
+17 -2
View File
@@ -17,6 +17,7 @@ type MatchReplaceRule struct {
MatchType string `json:"matchType"` // "string" or "regex"
Match string `json:"match"`
Replace string `json:"replace"`
Enabled bool `json:"enabled"` // per-rule gate, under the global toggle
compiled *regexp.Regexp // cached for regex rules
}
@@ -75,6 +76,7 @@ func (mr *MatchReplace) AddRule(target, matchType, match, replace string) MatchR
MatchType: matchType,
Match: match,
Replace: replace,
Enabled: true,
}
if matchType == "regex" {
rule.compiled, _ = regexp.Compile(match)
@@ -98,12 +100,25 @@ func (mr *MatchReplace) RemoveRule(id string) bool {
return false
}
// SetRuleEnabled toggles a single rule by ID. Returns true if found.
func (mr *MatchReplace) SetRuleEnabled(id string, enabled bool) bool {
mr.mu.Lock()
defer mr.mu.Unlock()
for i := range mr.rules {
if mr.rules[i].ID == id {
mr.rules[i].Enabled = enabled
return true
}
}
return false
}
// HasResponseRules reports whether any rules target response_header or response_body.
func (mr *MatchReplace) HasResponseRules() bool {
mr.mu.RLock()
defer mr.mu.RUnlock()
for _, r := range mr.rules {
if r.Target == "response_header" || r.Target == "response_body" {
if r.Enabled && (r.Target == "response_header" || r.Target == "response_body") {
return true
}
}
@@ -118,7 +133,7 @@ func (mr *MatchReplace) Apply(target string, data []byte) []byte {
return data
}
for _, r := range mr.rules {
if r.Target != target {
if r.Target != target || !r.Enabled {
continue
}
if r.MatchType == "regex" {
+32
View File
@@ -3,6 +3,7 @@ package proxy
import (
"net/url"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -666,6 +667,37 @@ func (s *Store) DeleteSitemapNode(origin, path string, matchPath bool) int {
return removed
}
// DeleteByHostPattern removes every captured request whose host matches the given
// host glob, using the same filepath.Match predicate (case-insensitive) that the
// NoiseFilter applies in IsNoisy and Scope applies in matchRule. It is the "clear
// what is already captured" companion to adding a noise pattern, so the rows removed
// are exactly the ones the pattern suppresses going forward. Host is derived like
// Sitemap/DeleteSitemapNode (u.Host); requests whose URL cannot be parsed are kept.
// Returns the number of requests removed.
func (s *Store) DeleteByHostPattern(hostPattern string) int {
s.mu.Lock()
defer s.mu.Unlock()
pat := strings.ToLower(hostPattern)
kept := s.items[:0]
removed := 0
for _, r := range s.items {
u, err := url.Parse(r.URL)
if err != nil {
kept = append(kept, r)
continue
}
matched, merr := filepath.Match(pat, strings.ToLower(u.Host))
if merr != nil || !matched {
kept = append(kept, r)
continue
}
removed++
}
s.items = kept
return removed
}
// Clear removes all stored requests.
func (s *Store) Clear() {
s.mu.Lock()
+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)
}
}
+2 -1
View File
@@ -32,7 +32,7 @@ import (
"github.com/BishopFox/joro/internal/xsshunter"
)
var version = "v1.17.0"
var version = "v1.18.3"
var commit = "dev" // injected via -ldflags at build time
func main() {
@@ -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
+818 -1287
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -23,12 +23,12 @@
"zustand": "^5.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.23",
"tailwindcss": "^3.4.14",
"tailwindcss": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^8.0.16"
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+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>
)
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState, type ReactNode } 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">
<Group title="Threat Feeds">
<p className="text-[11px] text-content-muted leading-relaxed">
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>
</Group>
</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">
<Group title="Feeds">
<p className="mb-3 text-[11px] text-content-muted leading-relaxed">
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>
<label className="flex items-start gap-2 text-xs text-content-secondary">
<input
type="checkbox"
className="mt-0.5 accent-accent-secondary"
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-[11px] text-content-muted leading-relaxed">
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="mt-2 flex items-start gap-2 text-xs text-content-secondary">
<input
type="checkbox"
className="mt-0.5 accent-accent-secondary"
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-[11px] text-content-muted leading-relaxed">
Off by default. Only has an effect when a SOCKS proxy is configured in General settings.
</span>
</span>
</label>
</Group>
<Group title="Sources">
<fieldset disabled={!settings.enabled} className="space-y-2 disabled:opacity-50">
{CATEGORY_LABELS.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-xs text-content-secondary">
<input
type="checkbox"
className="accent-accent-secondary"
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-[11px] text-content-muted">· {c.hint}</span>
</label>
))}
</fieldset>
</Group>
<Group title="Refresh">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-content-secondary">
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-xs 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-[11px] font-semibold 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-2 text-[11px] text-content-muted leading-relaxed">
Minimum {MIN_FEED_INTERVAL_MINUTES} minutes — NVD rate-limits unauthenticated clients.
</p>
</Group>
{sources.length > 0 && (
<Group title="Source status">
<ul className="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-xs">
<div className="min-w-0">
<span className="text-content-primary">{s.name}</span>
{s.lastError && (
<span className="block truncate text-[11px] text-semantic-error" title={s.lastError}>
{s.lastError}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5 text-[11px]">
{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>
</Group>
)}
</div>
)
}
// Group mirrors the titled sub-section used by the General/Appearance/Testing Browser
// Settings sections (see Settings.tsx) so this panel shares their type scale and rhythm.
function Group({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-2 mb-2">
<span className="w-1 h-3 rounded-full bg-accent-secondary shrink-0" />
<h3 className="text-[10px] font-semibold uppercase tracking-[0.14em] text-content-muted">{title}</h3>
</div>
{children}
</section>
)
}
+40 -19
View File
@@ -13,6 +13,7 @@ import { useTeamConnectionStore, type RelayState } from '../stores/teamConnectio
import { useTeamSharedConfigStore } from '../stores/teamSharedConfigStore'
import { useProjectStore } from '../stores/projectStore'
import { Redacted } from './Redacted'
import { Switch } from './Switch'
import { redactNow } from '../stores/streamerStore'
type FilterTab = 'scope' | 'noise' | 'replace'
@@ -663,23 +664,33 @@ export default function ProjectSettings() {
<div className="space-y-1 max-h-48 overflow-y-auto">
{replaceRules.map((rule) => (
<div key={rule.id} className="flex items-center gap-2 text-xs py-1 border-b border-border-subtle">
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-accent-secondary text-black">
{rule.target.replace('_', ' ')}
</span>
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-surface-input text-content-secondary">
{rule.matchType}
</span>
<span className="text-content-primary font-mono truncate max-w-[12rem]"><Redacted value={rule.match} kind="secret" /></span>
<span className="text-content-muted inline-flex items-center"><ArrowRight size={12} /></span>
<span className="text-semantic-success font-mono truncate max-w-[12rem]">
{rule.replace ? <Redacted value={rule.replace} kind="secret" /> : '(empty)'}
</span>
<div className={`flex items-center gap-2 min-w-0 flex-1 ${rule.enabled ? '' : 'opacity-50'}`}>
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-accent-secondary text-black">
{rule.target.replace('_', ' ')}
</span>
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-surface-input text-content-secondary">
{rule.matchType}
</span>
<span className="text-content-primary font-mono truncate max-w-[12rem]"><Redacted value={rule.match} kind="secret" /></span>
<span className="text-content-muted inline-flex items-center"><ArrowRight size={12} /></span>
<span className="text-semantic-success font-mono truncate max-w-[12rem]">
{rule.replace ? <Redacted value={rule.replace} kind="secret" /> : '(empty)'}
</span>
</div>
<Switch
checked={rule.enabled}
title={rule.enabled ? 'Disable rule' : 'Enable rule'}
onChange={async (next) => {
await api.setReplaceRuleEnabled(rule.id, next)
setReplaceRules((prev) => prev.map((r) => (r.id === rule.id ? { ...r, enabled: next } : r)))
}}
/>
<button
onClick={async () => {
await api.deleteReplaceRule(rule.id)
setReplaceRules((prev) => prev.filter((r) => r.id !== rule.id))
}}
className="ml-auto text-content-muted hover:text-semantic-error"
className="text-content-muted hover:text-semantic-error"
>
x
</button>
@@ -784,18 +795,28 @@ export default function ProjectSettings() {
<div className="space-y-1 max-h-48 overflow-y-auto">
{customDataItems.map((item) => (
<div key={item.id} className="flex items-center gap-2 text-xs py-1 border-b border-border-subtle">
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-accent-secondary text-black">
{item.type}
</span>
{item.name && <span className="text-content-primary font-mono">{item.name}</span>}
{item.name && <span className="text-content-muted">=</span>}
<span className="text-semantic-success font-mono truncate max-w-[20rem]">{item.value}</span>
<div className={`flex items-center gap-2 min-w-0 flex-1 ${item.enabled ? '' : 'opacity-50'}`}>
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-accent-secondary text-black">
{item.type}
</span>
{item.name && <span className="text-content-primary font-mono">{item.name}</span>}
{item.name && <span className="text-content-muted">=</span>}
<span className="text-semantic-success font-mono truncate max-w-[20rem]">{item.value}</span>
</div>
<Switch
checked={item.enabled}
title={item.enabled ? 'Disable item' : 'Enable item'}
onChange={async (next) => {
await api.setCustomDataItemEnabled(item.id, next)
setCustomDataItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, enabled: next } : i)))
}}
/>
<button
onClick={async () => {
await api.deleteCustomDataItem(item.id)
setCustomDataItems((prev) => prev.filter((i) => i.id !== item.id))
}}
className="ml-auto text-content-muted hover:text-semantic-error"
className="text-content-muted hover:text-semantic-error"
>
x
</button>
+17
View File
@@ -0,0 +1,17 @@
// Compact slider-style toggle switch, shared across settings panels.
export function Switch({ checked, onChange, title }: { checked: boolean; onChange: (v: boolean) => void; title?: string }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
title={title}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-4 w-8 shrink-0 items-center rounded-full transition-colors focus:outline-hidden focus-visible:ring-2 focus-visible:ring-accent-secondary ${
checked ? 'bg-accent-secondary' : 'bg-surface-input border border-border'
}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-content-primary transition-transform ${checked ? 'translate-x-4' : 'translate-x-0.5'}`} />
</button>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect } from 'react'
import { AlertTriangle } from 'lucide-react'
type Props = {
title?: string
message: string
confirmLabel?: string
onClose: () => void
}
/** A centered, single-button warning dialog for surfacing an error the operator
* must see — distinct from a transient toast. Mirrors ConfirmModal's overlay. */
export default function WarningModal({
title = 'Warning',
message,
confirmLabel = 'Dismiss',
onClose,
}: Props) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === 'Enter') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-6"
onMouseDown={onClose}
>
<div
className="flex flex-col w-full max-w-md bg-surface-card border border-border rounded shadow-lg overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<AlertTriangle size={15} strokeWidth={2} className="text-semantic-warning shrink-0" aria-hidden="true" />
<span className="text-xs font-semibold text-content-primary uppercase tracking-wide">{title}</span>
</div>
<div className="px-4 py-4">
<p className="text-sm text-content-primary break-words">{message}</p>
</div>
<div className="flex justify-end px-4 py-3 border-t border-border">
<button
onClick={onClose}
autoFocus
className="px-3 py-1.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary text-xs font-semibold"
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
+3 -3
View File
@@ -229,10 +229,10 @@ export default function ChainBinder({ chain, fromStep, valueB64, onCancel, onAcc
/** The readout that turns "this rule looks applicable" into "this rule returns
* the bytes you selected". */
function SourceProof({ ok, matched, err }: { ok: boolean; matched?: string; err?: string }) {
if (err) return <span className="block text-semantic-error">{err}</span>
if (ok) return <span className="block text-semantic-success">matches your selection</span>
if (err) return <span className="block text-[10px] text-semantic-error">{err}</span>
if (ok) return <span className="block text-[10px] text-semantic-success">matches your selection</span>
return (
<span className="block text-semantic-warning break-all">
<span className="block text-[10px] text-semantic-warning break-all">
matches {matched ? `"${matched.slice(0, 60)}"` : 'something else'}
</span>
)
@@ -285,7 +285,7 @@ export default function ChainStepInspector({
>
<div className="flex items-center justify-between gap-2">
<input
className="font-mono text-xs text-accent-secondary bg-transparent border-none outline-none min-w-0 flex-1"
className="font-mono text-xs text-accent-secondary bg-transparent border-none outline-hidden min-w-0 flex-1"
value={b.var}
onChange={(e) => patchBinding(b.id, { var: e.target.value })}
/>
@@ -67,7 +67,7 @@ export default function ActiveUsersWidget() {
value={settings?.teamStatus || 'online'}
onChange={(e) => changeStatus(e.target.value)}
disabled={!announcing}
className="flex-1 min-w-0 bg-surface-input text-content-primary text-xs px-1.5 py-1 rounded border border-border focus:outline-none focus:border-accent-secondary disabled:opacity-50"
className="flex-1 min-w-0 bg-surface-input text-content-primary text-xs px-1.5 py-1 rounded border border-border focus:outline-hidden focus:border-accent-secondary disabled:opacity-50"
>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
@@ -54,7 +54,7 @@ export default function AutomationActivityWidget() {
<DashboardPanel
title="Automation"
headerExtra={
<button onClick={goToSettings} className="text-[10px] text-content-muted hover:text-content-primary">
<button onClick={goToSettings} className="ml-auto float-right text-[10px] text-content-muted hover:text-content-primary">
Manage
</button>
}
+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>
)
}
@@ -266,7 +266,7 @@ export default function TeamChatWidget() {
}}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder={teamMode ? 'Type a message… (/flag <seq> to flag a request)' : 'Type a message...'}
className="flex-1 bg-surface-input text-content-primary text-xs px-2 py-1.5 rounded border border-border placeholder:text-content-muted focus:outline-none focus:border-accent-secondary"
className="flex-1 bg-surface-input text-content-primary text-xs px-2 py-1.5 rounded border border-border placeholder:text-content-muted focus:outline-hidden focus:border-accent-secondary"
/>
<button
onClick={sendMessage}
@@ -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>
)
}
+79 -35
View File
@@ -2,59 +2,95 @@ import { useEffect, useState } from 'react'
import { ArrowUpRight, ShieldAlert } from 'lucide-react'
import { useNavigate } from 'react-router'
import { api } from '../../lib/api'
import type { EchoEntry, EchoReflection } from '../../lib/echoTypes'
import type { EchoEntry, EchoReflection, EchoSpan } from '../../lib/echoTypes'
import {
CONTEXT_LABELS,
describeReflection,
prettyParamName,
SOURCE_LABELS,
TRANSFORM_LABELS,
survivedLabel,
} from '../../lib/echoTypes'
/** survivedLabel renders the characters that made it through readably. */
function survivedLabel(s?: string): string {
if (!s) return 'nothing'
return [...s]
.map((c) => (c === '\n' ? 'LF' : c === '\r' ? 'CR' : c))
.join(' ')
/** renderSnippet highlights the matched run inside the response snippet. */
function renderSnippet(snippet: string, match?: EchoSpan) {
if (!match) return snippet
const start = Math.max(0, Math.min(match.start, snippet.length))
const end = Math.max(start, Math.min(match.end, snippet.length))
return (
<>
{snippet.slice(0, start)}
<mark className="bg-transparent font-semibold text-semantic-warning">
{snippet.slice(start, end)}
</mark>
{snippet.slice(end)}
</>
)
}
function ReflectionRow({ r }: { r: EchoReflection }) {
const navigate = useNavigate()
return (
<div className="border-b border-border-subtle px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<div className="flex flex-wrap items-center gap-2">
{r.breakout && (
<span className="inline-flex items-center gap-1 text-semantic-error">
<ShieldAlert size={12} />
can leave context
<span className="inline-flex items-center gap-1 rounded bg-semantic-error-bg px-1.5 py-0.5 text-[11px] font-medium text-semantic-error">
<ShieldAlert size={12} /> Can leave context
</span>
)}
<span className="text-content-secondary">
{TRANSFORM_LABELS[r.transform] ?? r.transform}
</span>
<span className="text-content-muted">in</span>
<span className="text-content-secondary">
{CONTEXT_LABELS[r.context] ?? r.context}
{r.attr ? ` (${r.attr}${r.quote ? `, ${r.quote}-quoted` : ', unquoted'})` : ''}
{r.jsContext && r.jsContext !== 'code'
? ` / ${r.jsContext.replace('_', ' ')}`
: ''}
</span>
<span className="text-content-muted">{r.confidence} confidence</span>
<span className="text-xs text-content-primary">{describeReflection(r)}</span>
</div>
<div className="mt-1 font-mono text-[11px] text-content-muted">
survived: <span className="text-content-secondary">{survivedLabel(r.survived)}</span>
<span className="mx-2">·</span>
{r.part} bytes {r.span.start}-{r.span.end}
<div className="mt-1 font-mono text-[11px]">
<span className="text-content-muted">{SOURCE_LABELS[r.source] ?? r.source}:</span>{' '}
<span className="text-content-secondary">{prettyParamName(r.source, r.name)}</span>
</div>
{r.url && (
<div className="mt-1 flex items-center gap-2 text-[11px]">
<span className="truncate font-mono text-content-muted" title={r.url}>
{r.url}
</span>
{r.requestId && (
<button
className="inline-flex shrink-0 items-center gap-1 text-accent-secondary hover:underline"
onClick={() => navigate('/history', { state: { focusRequestId: r.requestId } })}
>
Open <ArrowUpRight size={11} />
</button>
)}
</div>
)}
{r.snippet ? (
<pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap break-all rounded bg-surface-input px-1.5 py-1 font-mono text-[11px] text-content-secondary">
{renderSnippet(r.snippet, r.snippetMatch)}
</pre>
) : (
<div className="mt-1 truncate font-mono text-[11px] text-content-muted">
value: {r.value}
</div>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-content-muted">
<span>
survived:{' '}
<span className={r.survived ? 'text-semantic-warning' : 'text-content-muted'}>
{survivedLabel(r.survived)}
</span>
</span>
<span>&middot;</span>
<span>
{r.part} bytes {r.span.start}-{r.span.end}
</span>
{r.coord === 'decoded-body' && (
<span
className="ml-2 text-semantic-info"
className="text-semantic-info"
title="The response was compressed, so these offsets index the decompressed body rather than the raw document."
>
decoded-body offsets
</span>
)}
</div>
<div className="mt-1 truncate font-mono text-[11px] text-content-muted">
value: {r.value}
<span>&middot;</span>
<span>{r.confidence} confidence</span>
</div>
</div>
)
@@ -81,12 +117,18 @@ export function EchoParamDetail({ entry }: { entry: EchoEntry }) {
}, [entry.id])
const examples = full?.examples ?? []
const pretty = prettyParamName(entry.source, entry.name)
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm text-content-primary">{entry.name}</span>
<span className="text-sm text-content-primary">{pretty}</span>
{pretty !== entry.name && (
<span className="rounded bg-surface-input px-1 py-0.5 font-mono text-[11px] text-content-muted">
{entry.name}
</span>
)}
<span className="rounded bg-surface-input px-1.5 py-0.5 text-[11px] text-content-muted">
{SOURCE_LABELS[entry.source] ?? entry.source}
</span>
@@ -94,7 +136,9 @@ export function EchoParamDetail({ entry }: { entry: EchoEntry }) {
{entry.exampleRequestId && (
<button
className="ml-auto inline-flex items-center gap-1 text-xs text-accent-secondary hover:underline"
onClick={() => navigate('/history', { state: { focusRequestId: entry.exampleRequestId } })}
onClick={() =>
navigate('/history', { state: { focusRequestId: entry.exampleRequestId } })
}
>
Open in History <ArrowUpRight size={12} />
</button>
+4 -6
View File
@@ -4,7 +4,9 @@ import { api } from '../../lib/api'
import type { EchoReport } from '../../lib/echoTypes'
import {
CONTEXT_LABELS,
prettyParamName,
SOURCE_LABELS,
survivedLabel,
TRANSFORM_LABELS,
} from '../../lib/echoTypes'
@@ -96,7 +98,7 @@ export function EchoRequestPane({ requestId }: { requestId: string }) {
<span className="text-content-muted">
{SOURCE_LABELS[r.source] ?? r.source}:
</span>{' '}
{r.name}
{prettyParamName(r.source, r.name)}
</td>
<td className="px-2 py-1 text-content-muted">
{TRANSFORM_LABELS[r.transform] ?? r.transform}
@@ -109,11 +111,7 @@ export function EchoRequestPane({ requestId }: { requestId: string }) {
: ''}
</td>
<td className="px-2 py-1 font-mono text-content-secondary">
{r.survived
? [...r.survived]
.map((c) => (c === '\n' ? 'LF' : c === '\r' ? 'CR' : c))
.join(' ')
: '-'}
{r.survived ? survivedLabel(r.survived) : '-'}
</td>
<td
className="px-2 py-1 text-right font-mono text-content-muted"
+3 -10
View File
@@ -1,6 +1,6 @@
import { ShieldAlert } from 'lucide-react'
import type { EchoSink, EchoTransform } from '../../lib/echoTypes'
import { CONTEXT_LABELS, TRANSFORM_LABELS } from '../../lib/echoTypes'
import { contextShort, describeSink, TRANSFORM_LABELS } from '../../lib/echoTypes'
/**
* Tone maps a transform to how much of the value reached the page intact. It is
@@ -19,12 +19,7 @@ function transformTone(t: EchoTransform): string {
}
export function EchoSinkPill({ sink }: { sink: EchoSink }) {
const ctx =
sink.context === 'attr_value' && sink.attr
? `${sink.attr}=`
: sink.context === 'script' && sink.jsContext && sink.jsContext !== 'code'
? `script ${sink.jsContext.replace('_', ' ')}`
: (CONTEXT_LABELS[sink.context] ?? sink.context)
const ctx = contextShort(sink)
return (
<span
@@ -32,9 +27,7 @@ export function EchoSinkPill({ sink }: { sink: EchoSink }) {
bg-surface-input px-1.5 py-0.5 text-[11px] leading-none ${
sink.breakout ? 'border-semantic-error' : ''
}`}
title={`${TRANSFORM_LABELS[sink.transform] ?? sink.transform} in ${
CONTEXT_LABELS[sink.context] ?? sink.context
}${sink.attr ? ` (${sink.attr})` : ''} - seen ${sink.count}x`}
title={`${describeSink(sink)} - seen ${sink.count}x`}
>
{sink.breakout && <ShieldAlert size={11} className="text-semantic-error" />}
<span className={transformTone(sink.transform)}>
@@ -113,7 +113,7 @@ export default function PayloadSourcePanel(props: Props) {
<textarea
value={wordlist}
onChange={(e) => props.onWordlistChange(e.target.value)}
className="flex-1 bg-surface-input text-xs font-mono px-2 py-1 resize-none outline-none text-content-primary"
className="flex-1 bg-surface-input text-xs font-mono px-2 py-1 resize-none outline-hidden text-content-primary"
placeholder={placeholder}
readOnly={disabled}
spellCheck={false}
@@ -288,7 +288,7 @@ function NumField({ label, value, disabled, onChange }: {
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))}
disabled={disabled}
className="w-28 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border outline-none"
className="w-28 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border outline-hidden"
/>
</div>
)
@@ -307,7 +307,7 @@ function TextField({ label, value, disabled, onChange, mono, placeholder }: {
disabled={disabled}
placeholder={placeholder}
spellCheck={false}
className={`flex-1 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border outline-none ${mono ? 'font-mono' : ''}`}
className={`flex-1 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border outline-hidden ${mono ? 'font-mono' : ''}`}
/>
</div>
)
+2 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Upload, Link2, ClipboardPaste, AlertTriangle, Search } from 'lucide-react'
import { Upload, Link2, ClipboardPaste, Search } from 'lucide-react'
import { api } from '../../lib/api'
import { b64DecodeUTF8 } from '../../lib/bytes'
import { useSJStore, type SJTab } from '../../stores/sjStore'
@@ -65,7 +65,7 @@ export default function SJDocumentModal({
}
async function load() {
store.updateTab(tab.id, { loading: true, loadError: '', htmlHint: null })
store.updateTab(tab.id, { loading: true, htmlHint: null })
try {
const body =
source === 'url'
@@ -206,13 +206,6 @@ export default function SJDocumentModal({
<Search size={12} /> Don't have one? Brute-force for it
</button>
</div>
{tab.loadError && (
<div className="flex items-start gap-2 text-xs text-semantic-error">
<AlertTriangle size={14} className="shrink-0 mt-px" />
<span>{tab.loadError}</span>
</div>
)}
</div>
)}
+39 -3
View File
@@ -17,9 +17,15 @@
@import './themes/takara.css';
@import './themes/tokyo.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@config '../tailwind.config.js';
/* v3 defaulted a bare `border` to gray-200; v4 defaults border-color to
currentColor. Pin the default to the theme token so every bare `border` reads
as the design-system border without annotating each call site. */
@layer base {
*, ::before, ::after { border-color: var(--color-border); }
}
button, a, select, input {
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease, opacity 150ms ease;
@@ -339,3 +345,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;
}
}
+24 -4
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'
@@ -104,6 +105,7 @@ export interface MatchReplaceRule {
matchType: string
match: string
replace: string
enabled: boolean
}
export interface CustomAddition {
@@ -111,6 +113,7 @@ export interface CustomAddition {
type: string
name: string
value: string
enabled: boolean
}
export interface Note {
@@ -177,6 +180,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 {
@@ -1185,6 +1191,10 @@ export const api = {
if (path !== undefined) qs.set('path', path)
return req<{ deleted: number }>('DELETE', `/sitemap?${qs.toString()}`)
},
// Delete captured requests whose host matches a glob (the host-level companion to
// adding a noise pattern). pattern is the host glob stored as the noise pattern.
deleteRequestsByHost: (pattern: string) =>
req<{ deleted: number }>('DELETE', `/requests/by-host?${new URLSearchParams({ pattern }).toString()}`),
// Every parameter the node's requests carried. The node is 'origin', not 'host'
// as deleteSitemapNode names it: this endpoint also takes the tree's filters,
// where 'host' means a substring of the captured Host header. Filters must match
@@ -1441,6 +1451,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 }>(
@@ -1471,13 +1489,15 @@ export const api = {
// Match & Replace
getReplace: () => req<{ enabled: boolean; rules: MatchReplaceRule[] }>('GET', '/replace'),
setReplaceEnabled: (enabled: boolean) => req<unknown>('PUT', '/replace/enabled', { enabled }),
addReplaceRule: (rule: Omit<MatchReplaceRule, 'id'>) => req<MatchReplaceRule>('POST', '/replace/rules', rule),
addReplaceRule: (rule: Omit<MatchReplaceRule, 'id' | 'enabled'>) => req<MatchReplaceRule>('POST', '/replace/rules', rule),
setReplaceRuleEnabled: (id: string, enabled: boolean) => req<unknown>('PUT', `/replace/rules/${id}/enabled`, { enabled }),
deleteReplaceRule: (id: string) => req<unknown>('DELETE', `/replace/rules/${id}`),
// Custom Data
getCustomData: () => req<{ enabled: boolean; items: CustomAddition[] }>('GET', '/customdata'),
setCustomDataEnabled: (enabled: boolean) => req<unknown>('PUT', '/customdata/enabled', { enabled }),
addCustomDataItem: (item: Omit<CustomAddition, 'id'>) => req<CustomAddition>('POST', '/customdata/items', item),
addCustomDataItem: (item: Omit<CustomAddition, 'id' | 'enabled'>) => req<CustomAddition>('POST', '/customdata/items', item),
setCustomDataItemEnabled: (id: string, enabled: boolean) => req<unknown>('PUT', `/customdata/items/${id}/enabled`, { enabled }),
deleteCustomDataItem: (id: string) => req<unknown>('DELETE', `/customdata/items/${id}`),
// WebSocket messages
@@ -1725,9 +1745,9 @@ export const api = {
scopeEnabled: scope.enabled,
scopeRules: scope.rules.map(({ pattern, methods, path, include }) => ({ pattern, methods, path, include })),
replaceEnabled: replace.enabled,
replaceRules: replace.rules.map(({ target, matchType, match, replace }) => ({ target, matchType, match, replace })),
replaceRules: replace.rules.map(({ target, matchType, match, replace, enabled }) => ({ target, matchType, match, replace, disabled: !enabled })),
customDataEnabled: custom.enabled,
customDataItems: custom.items.map(({ type, name, value }) => ({ type, name, value })),
customDataItems: custom.items.map(({ type, name, value, enabled }) => ({ type, name, value, disabled: !enabled })),
// Only custom rules travel; built-ins exist on every install.
detectRules: (detect?.rules ?? [])
.filter((r) => !r.builtin)
+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',
+81 -7
View File
@@ -45,6 +45,13 @@ export interface EchoReflection {
survived?: string
breakout: boolean
confidence: EchoConfidence
/** The specific message this occurrence came from (an Entry mixes several). */
requestId?: string
url?: string
/** A sanitized window of the response around the match, and where the match
* sits inside it (offsets into `snippet`, not the document). */
snippet?: string
snippetMatch?: EchoSpan
}
export interface EchoSink {
@@ -230,13 +237,80 @@ export const CONTEXT_OPTIONS = Object.keys(CONTEXT_LABELS) as EchoContext[]
export const TRANSFORM_OPTIONS = Object.keys(TRANSFORM_LABELS) as EchoTransform[]
export const SOURCE_OPTIONS = Object.keys(SOURCE_LABELS) as EchoSource[]
/**
* prettyParamName turns a server-synthesized positional name into something a
* human reads. The server indexes by position: a URL path segment becomes
* [1],[2],[3]; a root-level JSON array element is a bare [2]. Real names and
* dotted JSON paths (user.items[2].name) pass through untouched.
*/
export function prettyParamName(source: EchoSource, name: string): string {
const m = /^\[(\d+)\]$/.exec(name) // only a BARE positional token
if (m) {
if (source === 'path') return `path segment ${m[1]}`
if (source === 'json') return `array item ${m[1]}`
}
if (!name) return source === 'json' ? '(request body)' : '(unnamed)'
return name
}
/** transformWord names an encoding as a mid-sentence word. */
export function transformWord(t: EchoTransform): string {
return t === 'identity' ? 'unencoded' : TRANSFORM_LABELS[t] ?? t
}
/** A shape both EchoReflection and EchoSink satisfy, so one set of context
* helpers serves reflections, sinks and request rows. */
type Contextual = Pick<EchoReflection, 'context' | 'attr' | 'quote' | 'jsContext'>
/** contextShort is the compact noun for a pill or a table cell. */
export function contextShort(x: Contextual): string {
if (x.context === 'attr_value' && x.attr) return `${x.attr}=`
if (x.context === 'script' && x.jsContext && x.jsContext !== 'code')
return `script ${x.jsContext.replace('_', ' ')}`
return CONTEXT_LABELS[x.context] ?? x.context
}
/** contextProse is the full phrase for a sentence; mirrors the server's
* describe() in internal/echo/finding.go. */
export function contextProse(x: Contextual): string {
switch (x.context) {
case 'attr_value': {
// A reflection reports quote '' for a genuinely unquoted value; a sink
// carries no quote at all, so only claim quotedness when it is known.
if (x.quote) {
return x.attr
? `the ${x.quote}-quoted ${x.attr} attribute`
: `a ${x.quote}-quoted attribute`
}
if (x.quote === '') {
return x.attr ? `an unquoted ${x.attr} attribute` : 'an unquoted attribute'
}
return x.attr ? `the ${x.attr} attribute` : 'an attribute'
}
case 'script':
return !x.jsContext || x.jsContext === 'code'
? 'script code'
: `a script ${x.jsContext.replace('_', ' ')}`
case 'header_value':
return 'a response header'
default:
return CONTEXT_LABELS[x.context] ?? x.context
}
}
/** describeReflection is the plain-English lead for a reflection row, e.g.
* "Reflected unencoded into the "-quoted href attribute". */
export function describeReflection(r: EchoReflection): string {
return `Reflected ${transformWord(r.transform)} into ${contextProse(r)}`
}
/** survivedLabel renders surviving metacharacters readably (LF/CR named). */
export function survivedLabel(s?: string): string {
if (!s) return 'nothing'
return [...s].map((c) => (c === '\n' ? 'LF' : c === '\r' ? 'CR' : c)).join(' ')
}
/** describeSink renders one sink as a short phrase for a row. */
export function describeSink(s: EchoSink): string {
const ctx =
s.context === 'attr_value' && s.attr
? `${s.attr} attribute`
: s.context === 'script' && s.jsContext && s.jsContext !== 'code'
? `script ${s.jsContext.replace('_', ' ')}`
: CONTEXT_LABELS[s.context] ?? s.context
return `${TRANSFORM_LABELS[s.transform] ?? s.transform} in ${ctx}`
return `${TRANSFORM_LABELS[s.transform] ?? s.transform} in ${contextProse(s)}`
}
+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')
}
+161
View File
@@ -0,0 +1,161 @@
import type { MenuItem } from '../components/ContextMenu'
import { api } from './api'
import { useToastStore } from '../stores/toastStore'
import { useRequestStore } from '../stores/requestStore'
// Host-level context-menu actions shared by the History table rows and the Site Map host
// menu. Modeled on getSelectionMenuItems. Scope and noise both match a host glob with
// filepath.Match (internal/proxy/scope.go, noise.go), so patterns are the host verbatim.
// hostnameOnly strips a trailing :port, leaving IPv6 bracket forms intact.
function hostnameOnly(host: string): string {
const close = host.lastIndexOf(']')
const colon = host.lastIndexOf(':')
return colon > close ? host.slice(0, colon) : host
}
function toast(msg: string, type: 'error' | 'info' = 'info') {
useToastStore.getState().addToast(msg, type)
}
async function addScope(pattern: string, path: string, label: string) {
try {
const { enabled } = await api.getScope()
await api.addScopeRule({ pattern, methods: [], path, include: true })
toast(enabled ? `Added ${label} to scope` : `Added ${label} to scope — scope filtering is off`)
} catch {
toast('Failed to add scope rule', 'error')
}
}
// addNoise adds a noise pattern and then purges the already-captured requests it matches:
// noise means traffic to never see, so leaving the old rows behind would be the surprise.
// invalidate() reloads History and, via the total change, the Site Map (both derive from the
// same store). The deletion predicate is the server's, matching the pattern just stored.
async function addNoise(pattern: string, label: string) {
try {
const { enabled } = await api.getNoise()
await api.addNoisePattern(pattern)
const { deleted } = await api.deleteRequestsByHost(pattern)
if (deleted > 0) useRequestStore.getState().invalidate()
const cleared = deleted > 0 ? ` — cleared ${deleted} request${deleted === 1 ? '' : 's'}` : ''
toast(enabled ? `Added ${label} to noise filter${cleared}` : `Added ${label} to noise filter — noise filtering is off${cleared}`)
} catch {
toast('Failed to add noise pattern', 'error')
}
}
// hostMatchesGlob is a best-effort mirror of filepath.Match for host patterns (which carry no
// path separator): * matches any run of characters, ? matches one. Advisory only — it drives a
// warning, never a scope decision, so an exotic char-class pattern merely skips the warning.
function hostMatchesGlob(pattern: string, host: string): boolean {
let re = ''
for (const c of pattern) {
if (c === '*') re += '.*'
else if (c === '?') re += '.'
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
}
try {
return new RegExp(`^${re}$`).test(host)
} catch {
return false
}
}
// excludeScope adds an exclude rule (include:false) for a scope target, but only if an identical
// one does not already exist. It warns when the rule would currently have no effect: excludes
// only subtract from an included host, so with scope off, or with no include rule matching this
// host, there is nothing in scope for it to remove.
async function excludeScope(pattern: string, path: string, label: string, host: string) {
try {
const { enabled, rules } = await api.getScope()
const exists = rules.some(
(r) => !r.include && r.pattern === pattern.toLowerCase() && r.path === path,
)
if (exists) {
toast(`${label} is already excluded from scope`)
return
}
await api.addScopeRule({ pattern, methods: [], path, include: false })
const h = host.toLowerCase()
if (!enabled) {
toast(`Excluded ${label} — scope filtering is off`)
} else if (!rules.some((r) => r.include && hostMatchesGlob(r.pattern, h))) {
toast(`Excluded ${label} — no in-scope rule matches ${host}, so this has no effect`, 'error')
} else {
toast(`Excluded ${label} from scope`)
}
} catch {
toast('Failed to add exclude rule', 'error')
}
}
// removeHostFromScope deletes the include rules this feature creates for a host: the exact
// host (This host / Exact URL) and the subdomain wildcard. It leaves operator-authored broad
// wildcards and exclude rules alone.
async function removeHostFromScope(host: string) {
try {
const exact = host.toLowerCase()
const wildcard = `*.${hostnameOnly(host).toLowerCase()}`
const { rules } = await api.getScope()
const victims = rules.filter((r) => r.include && (r.pattern === exact || r.pattern === wildcard))
if (victims.length === 0) {
toast(`No scope rules matched ${host}`)
return
}
await Promise.all(victims.map((r) => api.deleteScopeRule(r.id)))
toast(`Removed ${victims.length} scope rule${victims.length === 1 ? '' : 's'} for ${host}`)
} catch {
toast('Failed to remove scope rules', 'error')
}
}
// scopeTargets lists the scope targets for a host: the exact host, the subdomain wildcard, and
// (when a URL is available) the request's exact path. Shared by the scope-add and scope-exclude
// submenus so the two never drift.
function scopeTargets(host: string, opts?: { url?: string }) {
const wildcard = `*.${hostnameOnly(host)}`
const targets = [
{ label: 'This host', pattern: host, path: '', display: host },
{ label: 'All subdomains', pattern: wildcard, path: '', display: wildcard },
]
if (opts?.url) {
try {
const path = new URL(opts.url).pathname
targets.push({ label: 'Exact URL', pattern: host, path, display: `${host}${path}` })
} catch {
// Unparseable URL: skip the path-scoped option.
}
}
return targets
}
// getHostMenuItems builds the host actions for a context menu. When opts.url is given, the
// scope submenus also offer the request's exact URL (host + path); noise has no path field,
// so its submenu never does.
export function getHostMenuItems(host: string, opts?: { url?: string }): MenuItem[] {
const wildcard = `*.${hostnameOnly(host)}`
const targets = scopeTargets(host, opts)
const scopeChildren: MenuItem[] = targets.map((t) => ({
label: t.label,
onClick: () => void addScope(t.pattern, t.path, t.display),
}))
const excludeChildren: MenuItem[] = targets.map((t) => ({
label: t.label,
onClick: () => void excludeScope(t.pattern, t.path, t.display, host),
}))
return [
{ label: 'Add host to scope', children: scopeChildren },
{
label: 'Add host to noise filter',
children: [
{ label: 'This host', onClick: () => void addNoise(host, host) },
{ label: 'All subdomains', onClick: () => void addNoise(wildcard, wildcard) },
],
},
{ label: 'Exclude from scope', children: excludeChildren },
{ label: 'Remove host from scope', onClick: () => void removeHostFromScope(host) },
]
}
+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>
)
}
+18 -6
View File
@@ -11,6 +11,7 @@ import type { EchoContext, EchoSource, EchoTransform } from '../lib/echoTypes'
import {
CONTEXT_LABELS,
CONTEXT_OPTIONS,
prettyParamName,
SOURCE_LABELS,
SOURCE_OPTIONS,
TRANSFORM_LABELS,
@@ -115,14 +116,15 @@ export default function Echo() {
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="flex flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-1.5 text-xs text-content-secondary">
<input
type="checkbox"
className="accent-accent"
checked={enabled}
disabled={busy}
onChange={(e) => void toggleEnabled(e.target.checked)}
/>
<span className="text-content-primary">Reflection mapping</span>
Reflection mapping
</label>
<span className="text-xs text-content-muted">
@@ -241,16 +243,17 @@ export default function Echo() {
<th className="px-2 py-1 font-normal">Parameter</th>
<th className="px-2 py-1 font-normal">Source</th>
<th className="px-2 py-1 font-normal">Host</th>
<th className="px-2 py-1 font-normal">Example</th>
<th className="px-2 py-1 font-normal">Lands in</th>
<th className="px-2 py-1 text-right font-normal">Seen</th>
<th className="px-2 py-1 text-right font-normal">Values</th>
<th className="px-2 py-1 text-right font-normal">Distinct</th>
<th className="px-2 py-1 font-normal">Last</th>
</tr>
</thead>
<tbody>
{items.length === 0 && !loading && (
<tr>
<td colSpan={7} className="px-2 py-8 text-center text-xs text-content-muted">
<td colSpan={8} className="px-2 py-8 text-center text-xs text-content-muted">
{!enabled
? 'Reflection mapping is off. Switch it on, then browse through the proxy or run a backfill.'
: summary.params === 0
@@ -267,16 +270,25 @@ export default function Echo() {
}`}
onClick={() => setSelected(e)}
>
<td className="max-w-[16rem] truncate px-2 py-1 text-content-secondary">
<td
className="max-w-[16rem] truncate px-2 py-1 text-content-secondary"
title={`${e.name}${e.exampleUrl ? `\n${e.exampleUrl}` : ''}`}
>
{e.breakouts > 0 && (
<ShieldAlert size={12} className="mr-1 inline text-semantic-error" />
)}
{e.name}
{prettyParamName(e.source, e.name)}
</td>
<td className="px-2 py-1 text-content-muted">
{SOURCE_LABELS[e.source] ?? e.source}
</td>
<td className="max-w-[12rem] truncate px-2 py-1 text-content-muted">{e.host}</td>
<td
className="max-w-[12rem] truncate px-2 py-1 font-mono text-content-secondary"
title={e.exampleValue}
>
{e.exampleValue || <span className="text-content-muted">&mdash;</span>}
</td>
<td className="px-2 py-1">
<div className="flex flex-wrap gap-1">
{e.sinks.slice(0, 4).map((s, i) => (
+1 -1
View File
@@ -598,7 +598,7 @@ export default function Fuzz() {
onBlur={commitRename}
onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTabId(null) }}
onClick={(e) => e.stopPropagation()}
className="bg-surface-input text-xs px-1 py-0 rounded-sm border border-border w-16 outline-none"
className="bg-surface-input text-xs px-1 py-0 rounded-sm border border-border w-16 outline-hidden"
/>
) : (
<span>{t.name}</span>
+20 -3
View File
@@ -23,6 +23,7 @@ import ContextMenu from '../components/ContextMenu'
import ConfirmModal from '../components/ConfirmModal'
import { Tooltip } from '../components/Tooltip'
import { getSelectionMenuItems } from '../lib/selectionMenu'
import { getHostMenuItems } from '../lib/hostMenu'
import { initiateScan } from '../lib/scanMenu'
import { useScanOptionsStore } from '../stores/scanOptionsStore'
import { copyText } from '../lib/clipboard'
@@ -640,6 +641,16 @@ function HTTPHistory() {
copyText(rawToCurl(raw, selectedDetail.url))
}
// Row menu carries only the summary, so fetch the raw bytes to build the curl command.
async function copyCurlRow(id: string) {
try {
const d = (await api.getRequest(id)) as RequestDetail
copyText(rawToCurl(b64Decode(d.reqRaw), d.url))
} catch {
addToast('Failed to copy curl')
}
}
function copyRaw(tab: 'request' | 'response') {
if (!selectedDetail) return
const raw = tab === 'request' ? selectedDetail.reqRaw : selectedDetail.respRaw
@@ -700,9 +711,8 @@ function HTTPHistory() {
// The right-clicked row's capture sequence. Taken from the summary the table
// already holds rather than from the loaded detail, because the row menu can
// open on a row that is not the selected one.
const rowSeq = rowMenu
? sortedItems.find((i) => i.id === rowMenu.itemId)?.seq
: undefined
const rowSummary = rowMenu ? sortedItems.find((i) => i.id === rowMenu.itemId) : undefined
const rowSeq = rowSummary?.seq
return (
<div className="flex flex-col flex-1 min-h-0" ref={vSplit.containerRef}>
@@ -1162,6 +1172,13 @@ function HTTPHistory() {
}),
},
{ label: 'Stage for Dead Drop', onClick: () => stageForDeadDrop(rowMenu.itemId) },
...(rowSummary ? getHostMenuItems(rowSummary.host, { url: rowSummary.url }) : []),
...(rowSummary
? [
{ label: 'Copy URL', onClick: () => copyText(rowSummary.url) },
{ label: 'Copy as curl', onClick: () => copyCurlRow(rowSummary.id) },
]
: []),
]}
/>
)}
+2 -2
View File
@@ -62,7 +62,7 @@ export default function Login({ onAuthenticated }: Props) {
onChange={(e) => setNickname(e.target.value)}
placeholder="Your display name"
autoFocus
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-none focus:border-accent-secondary joro-redact-field"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-hidden focus:border-accent-secondary joro-redact-field"
/>
</div>
@@ -73,7 +73,7 @@ export default function Login({ onAuthenticated }: Props) {
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste the listener auth token"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-none focus:border-accent-secondary"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-hidden focus:border-accent-secondary"
/>
</div>
+1 -1
View File
@@ -299,7 +299,7 @@ export default function ManipulateHTTP() {
onBlur={commitRename}
onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTabId(null) }}
onClick={(e) => e.stopPropagation()}
className="bg-surface-input text-xs px-1 py-0 rounded-sm border border-border w-16 outline-none"
className="bg-surface-input text-xs px-1 py-0 rounded-sm border border-border w-16 outline-hidden"
/>
) : (
<span>{t.name}</span>
+1 -1
View File
@@ -260,7 +260,7 @@ export default function ManipulateWS() {
onBlur={commitRename}
onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTabId(null) }}
onClick={(e) => e.stopPropagation()}
className="bg-surface-input text-xs px-1 py-0 rounded-sm border border-border w-16 outline-none"
className="bg-surface-input text-xs px-1 py-0 rounded-sm border border-border w-16 outline-hidden"
/>
) : (
<span>{t.name}</span>
+7
View File
@@ -24,6 +24,7 @@ import { EchoRequestPane } from '../components/echo/EchoRequestPane'
import { ParamInventory } from '../components/echo/ParamInventory'
import { RequestParams } from '../components/echo/RequestParams'
import { getSelectionMenuItems } from '../lib/selectionMenu'
import { getHostMenuItems } from '../lib/hostMenu'
import { copyText } from '../lib/clipboard'
import SitemapFilterModal, { emptySitemapFilter, hasModalFilters } from '../components/SitemapFilterModal'
import type { SitemapFilter } from '../components/SitemapFilterModal'
@@ -41,6 +42,11 @@ function b64Decode(s: string) {
try { return atob(s) } catch { return s }
}
// A sitemap host node's origin is a full origin URL; the host menu wants the bare host.
function hostFromOrigin(origin: string): string {
try { return new URL(origin).host } catch { return origin }
}
export default function Map() {
const navigate = useNavigate()
const [hosts, setHosts] = useState<SitemapHost[]>([])
@@ -709,6 +715,7 @@ export default function Map() {
}
},
},
...getHostMenuItems(hostFromOrigin(hostMenu.origin)),
]}
/>
)}
+3 -3
View File
@@ -195,7 +195,7 @@ export default function Notes({ teamMode = false }: NotesProps) {
onChange={(e) => setNewHost(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addHost()}
placeholder="Add host..."
className="flex-1 min-w-0 bg-surface-input text-content-primary text-xs px-2 py-1 rounded border border-border placeholder:text-content-muted focus:outline-none focus:border-accent-secondary"
className="flex-1 min-w-0 bg-surface-input text-content-primary text-xs px-2 py-1 rounded border border-border placeholder:text-content-muted focus:outline-hidden focus:border-accent-secondary"
/>
<button
onClick={addHost}
@@ -270,7 +270,7 @@ export default function Notes({ teamMode = false }: NotesProps) {
}}
rows={3}
autoFocus
className="w-full bg-surface-input text-content-primary text-xs px-2 py-1 rounded border border-border focus:outline-none focus:border-accent-secondary resize-y"
className="w-full bg-surface-input text-content-primary text-xs px-2 py-1 rounded border border-border focus:outline-hidden focus:border-accent-secondary resize-y"
/>
<div className="flex gap-2">
<button
@@ -322,7 +322,7 @@ export default function Notes({ teamMode = false }: NotesProps) {
onChange={(e) => setNoteDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addNote()}
placeholder="Add a note..."
className="flex-1 bg-surface-input text-content-primary text-xs px-2 py-1.5 rounded border border-border placeholder:text-content-muted focus:outline-none focus:border-accent-secondary"
className="flex-1 bg-surface-input text-content-primary text-xs px-2 py-1.5 rounded border border-border placeholder:text-content-muted focus:outline-hidden focus:border-accent-secondary"
/>
<button
onClick={addNote}
+18 -5
View File
@@ -7,6 +7,7 @@ import type { SJNavState } from '../lib/sjTypes'
import type { RequestDetail } from '../stores/requestStore'
import SJDocumentBar from '../components/sj/SJDocumentBar'
import SJDocumentModal, { type DocTab } from '../components/sj/SJDocumentModal'
import WarningModal from '../components/WarningModal'
import SJProfilesModal from '../components/sj/SJProfilesModal'
import SJOperationsView from '../components/sj/SJOperationsView'
import SJAutomateView from '../components/sj/SJAutomateView'
@@ -42,12 +43,15 @@ export default function SJ() {
const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]
const [docModal, setDocModal] = useState<DocTab | null>(null)
const [profilesOpen, setProfilesOpen] = useState(false)
const [loadErrorMsg, setLoadErrorMsg] = useState<string | null>(null)
/** applyLoadFailure turns a rejection into either an error or, for the very
* common "that was the Swagger UI page" case, a pre-filled Brute tab. */
/** applyLoadFailure turns a rejection into either an error or, when a URL
* served an HTML page instead of a document, a pre-filled Brute tab. The
* verdict and its message come from the server, which has the bytes and
* decides whether the page is an API-documentation UI or just HTML. */
const applyLoadFailure = useCallback((tabId: string, e: unknown) => {
const err = e as { message?: string; body?: unknown }
const body = (err as { body?: { kind?: string; candidates?: string[]; sourceUrl?: string } }).body
const body = (err as { body?: { kind?: string; error?: string; candidates?: string[]; sourceUrl?: string } }).body
if (body?.kind === 'html') {
const t = useSJStore.getState().tabs.find((x) => x.id === tabId)
store.updateTab(tabId, {
@@ -55,7 +59,7 @@ export default function SJ() {
subTab: 'brute',
htmlHint: {
message:
'That URL served the Swagger UI page, not a document. These are the paths it loads from.',
body.error ?? 'That URL served an HTML page, not an API description document.',
candidates: body.candidates ?? [],
sourceUrl: body.sourceUrl ?? '',
},
@@ -66,7 +70,8 @@ export default function SJ() {
})
return
}
store.updateTab(tabId, { loading: false, loadError: String(err?.message ?? e) })
store.updateTab(tabId, { loading: false })
setLoadErrorMsg(String(err?.message ?? e))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -205,6 +210,14 @@ export default function SJ() {
/>
)}
{loadErrorMsg && (
<WarningModal
title="Couldn't load document"
message={loadErrorMsg}
onClose={() => setLoadErrorMsg(null)}
/>
)}
{profilesOpen && (
<SJProfilesModal
profiles={tab.profiles}
+12 -19
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'
@@ -13,11 +14,12 @@ import { useDashboardLayoutStore } from '../stores/dashboardLayoutStore'
import DashboardLayoutSettings from '../components/DashboardLayoutSettings'
import ConfirmModal from '../components/ConfirmModal'
import HealthCheck from '../components/HealthCheck'
import { Switch } from '../components/Switch'
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 +42,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 +80,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 +264,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} />
)}
@@ -608,23 +618,6 @@ function SubLabel({ children }: { children: ReactNode }) {
return <div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-content-muted mt-2.5 mb-1.5">{children}</div>
}
function Switch({ checked, onChange, title }: { checked: boolean; onChange: (v: boolean) => void; title?: string }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
title={title}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-4 w-8 shrink-0 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-secondary ${
checked ? 'bg-accent-secondary' : 'bg-surface-input border border-border'
}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-content-primary transition-transform ${checked ? 'translate-x-4' : 'translate-x-0.5'}`} />
</button>
)
}
function ValueChip({ children }: { children: ReactNode }) {
return <code className="font-mono text-[11px] bg-surface-input text-content-primary px-1.5 py-0.5 rounded-sm">{children}</code>
}
+3 -3
View File
@@ -113,7 +113,7 @@ export default function Setup({ onSetupComplete }: Props) {
value={listenerUrl}
onChange={(e) => setListenerUrl(e.target.value)}
placeholder="http://listener:9090"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-none focus:border-accent-secondary joro-redact-field"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-hidden focus:border-accent-secondary joro-redact-field"
/>
</div>
@@ -124,7 +124,7 @@ export default function Setup({ onSetupComplete }: Props) {
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste the listener auth token"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-none focus:border-accent-secondary"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-hidden focus:border-accent-secondary"
/>
</div>
@@ -135,7 +135,7 @@ export default function Setup({ onSetupComplete }: Props) {
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="Your display name"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-none focus:border-accent-secondary joro-redact-field"
className="w-full bg-surface-input text-content-primary text-xs px-3 py-2 rounded border border-border placeholder:text-content-muted focus:outline-hidden focus:border-accent-secondary joro-redact-field"
/>
</div>
+7 -7
View File
@@ -309,7 +309,7 @@ export default function Transform() {
{sections.map((section, idx) => (
<div key={section.id} className="flex flex-col flex-1 min-h-0">
{idx > 0 && (
<div className="flex items-center justify-center py-1 flex-shrink-0">
<div className="flex items-center justify-center py-1 shrink-0">
<div className="text-content-muted inline-flex items-center"><ChevronDown size={14} /></div>
</div>
)}
@@ -319,7 +319,7 @@ export default function Transform() {
onChange={(e) => updateText(section.id, e.target.value)}
placeholder={idx === 0 ? 'Enter text to transform...' : 'Result'}
rows={4}
className="w-full flex-1 min-h-[6rem] bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-none focus:border-accent-secondary"
className="w-full flex-1 min-h-[6rem] bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-hidden focus:border-accent-secondary"
/>
<div className="flex items-center gap-2 mt-2">
<select
@@ -327,7 +327,7 @@ export default function Transform() {
onChange={(e) =>
updateEncoding(section.id, e.target.value as Encoding)
}
className="bg-surface-input text-content-primary text-xs rounded px-2 py-1.5 border border-border focus:outline-none focus:border-accent-secondary"
className="bg-surface-input text-content-primary text-xs rounded px-2 py-1.5 border border-border focus:outline-hidden focus:border-accent-secondary"
>
{ENCODINGS.map((enc) => (
<option key={enc.value} value={enc.value}>
@@ -369,7 +369,7 @@ export default function Transform() {
onChange={(e) => setHashInput(e.target.value)}
placeholder="Enter text to hash..."
rows={4}
className="w-full bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-none focus:border-accent-secondary"
className="w-full bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-hidden focus:border-accent-secondary"
/>
{hashInput && (
<div className="mt-3 space-y-2">
@@ -422,7 +422,7 @@ export default function Transform() {
onChange={(e) => setJwtInput(e.target.value)}
placeholder="Paste a JWT (eyJ...)..."
rows={3}
className="w-full bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-none focus:border-accent-secondary"
className="w-full bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-hidden focus:border-accent-secondary"
/>
{jwtError && (
<div className="text-semantic-error text-xs mt-1">{jwtError}</div>
@@ -433,7 +433,7 @@ export default function Transform() {
onChange={(e) => setJwtHeader(e.target.value)}
placeholder='{"alg": "HS256", "typ": "JWT"}'
rows={4}
className="w-full bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-none focus:border-accent-secondary"
className="w-full bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-hidden focus:border-accent-secondary"
/>
<label className="text-xs text-content-secondary font-semibold mt-3 mb-1">Payload</label>
<textarea
@@ -441,7 +441,7 @@ export default function Transform() {
onChange={(e) => setJwtPayload(e.target.value)}
placeholder='{"sub": "1234567890", "name": "John Doe"}'
rows={6}
className="w-full bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-none focus:border-accent-secondary flex-1"
className="w-full bg-surface-input text-content-primary text-sm font-mono rounded p-2 border border-border placeholder:text-content-muted resize-y focus:outline-hidden focus:border-accent-secondary flex-1"
/>
<div className="flex gap-2 mt-2">
<button
+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 })
}
},
}))
-3
View File
@@ -158,7 +158,6 @@ export interface SJTab {
spec: SJSpec | null
loading: boolean
loadError: string
/** Set when a load returned "that was the UI page, not a document". Carries
* the paths Discover should be pre-filled with. */
htmlHint: { message: string; candidates: string[]; sourceUrl: string } | null
@@ -212,7 +211,6 @@ function makeTab(id: string, name: string, partial?: Partial<SJTab>): SJTab {
name,
spec: null,
loading: false,
loadError: '',
htmlHint: null,
subTab: 'operations',
serverIndex: 0,
@@ -432,7 +430,6 @@ export const useSJStore = create<SJState>((set, get) => ({
return patchTab(s, tabId, {
spec: normalized,
loading: false,
loadError: '',
htmlHint: null,
name: normalized.title || 'Untitled',
serverIndex: 0,
+4 -2
View File
@@ -19,9 +19,11 @@ export interface SharedConfigPayload {
scopeEnabled: boolean
scopeRules: { pattern: string; methods: string[]; path: string; include: boolean }[]
replaceEnabled: boolean
replaceRules: { target: string; matchType: string; match: string; replace: string }[]
// Per-rule state travels as a disabled flag (absent = enabled), matching the
// server's sharedConfigPayload DTO. Live API uses enabled; this bundle uses disabled.
replaceRules: { target: string; matchType: string; match: string; replace: string; disabled?: boolean }[]
customDataEnabled: boolean
customDataItems: { type: string; name: string; value: string }[]
customDataItems: { type: string; name: string; value: string; disabled?: boolean }[]
// Custom detection rules travel with the bundle; findings do not.
detectRules?: Record<string, unknown>[]
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:9090',