mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-08 08:04:05 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
376880c88d | ||
|
|
f7097f22d1 | ||
|
|
bbe68abe7c | ||
|
|
ee6083e311 | ||
|
|
0f7b59fd0b | ||
|
|
78945ed001 | ||
|
|
647aebdba6 | ||
|
|
4b3c4766e2 | ||
|
|
e6a9f1f348 | ||
|
|
01ba289b5e | ||
|
|
762c85454c | ||
|
|
be27327a0d | ||
|
|
f7889a0e1d | ||
|
|
5cdb71ac24 | ||
|
|
976e518298 | ||
|
|
f80333451e | ||
|
|
bc446b1d64 | ||
|
|
ab06f4dbdb |
No files matched your search
@@ -1,12 +1,11 @@
|
||||
# Joro
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
> 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.
|
||||
|
||||

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