mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Add active scanning feature with DOM XSS detection
This commit is contained in:
1 parent
aeb688148b
commit
6e3c834df4
24 files changed
+2143
-25
No files matched your search
@@ -0,0 +1,251 @@
|
||||
// Package activescan is Joro's operator-initiated active scanning: unlike detect,
|
||||
// echo and anomaly — which only ever read captured traffic — a scan originates
|
||||
// traffic or drives a browser against a target the operator picked, and files
|
||||
// confirmed results into the shared detect.Store.
|
||||
//
|
||||
// # A framework, not one check
|
||||
//
|
||||
// A scan runs a set of pluggable Rules against one Target. DOM XSS is the first
|
||||
// and only rule today; the interface exists so a future active rule (a reflected-
|
||||
// XSS prober, an open-redirect check) plugs in without the run machinery, the
|
||||
// scope rule or the context-menu wiring changing. The registry is an instance a
|
||||
// caller builds and owns, not a package global, per the project's no-globals rule.
|
||||
//
|
||||
// # One run at a time
|
||||
//
|
||||
// Like internal/chainrun, a scan is sequential and the store refuses a second
|
||||
// concurrent run (the AddIfIdle guard). A DOM XSS scan drives a single browser
|
||||
// and a future state-changing rule would race the application's own state, so
|
||||
// coexisting runs would describe the interleaving rather than the target.
|
||||
//
|
||||
// # Scope is a filter, not a gate
|
||||
//
|
||||
// The target's URLs are scope-filtered by the handler through proxy.Scope.InScope,
|
||||
// which returns true whenever scope filtering is disabled. So with scope off — the
|
||||
// default — nothing is rejected and a scan covers every target; scope only narrows
|
||||
// a scan once the operator turns it on. Rules therefore receive an already-filtered
|
||||
// URL list and never re-check.
|
||||
package activescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/BishopFox/joro/internal/cert"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// Status is a run's state.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusComplete Status = "complete"
|
||||
StatusStopped Status = "stopped"
|
||||
)
|
||||
|
||||
// Bounds.
|
||||
const (
|
||||
MaxRuns = 20
|
||||
|
||||
// MaxURLs caps a single host scan: a large site map should not turn one click
|
||||
// into thousands of navigations.
|
||||
MaxURLs = 200
|
||||
|
||||
DefaultBudgetMs = 600000 // 10 minutes
|
||||
MaxBudgetMs = 1800000 // 30 minutes
|
||||
)
|
||||
|
||||
// TargetURL is one address to exercise.
|
||||
type TargetURL struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
RequestID string `json:"requestId,omitempty"` // the capture it came from, if any
|
||||
// RawReq carries the captured request bytes for a future HTTP-sending rule.
|
||||
// The DOM XSS rule navigates to URL and ignores it.
|
||||
RawReq []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Target is what a scan operates on: one host's endpoints, or a single request.
|
||||
type Target struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"` // Host-header form (may carry :port)
|
||||
Origin string `json:"origin"` // scheme://host
|
||||
URLs []TargetURL `json:"-"`
|
||||
}
|
||||
|
||||
// Config is snapshotted when a run starts.
|
||||
type Config struct {
|
||||
// Scope records how the target was chosen, for display: "host" or "request".
|
||||
Scope string
|
||||
|
||||
// Rules names the rule IDs to run; empty means every registered rule.
|
||||
Rules []string
|
||||
|
||||
// AllowDestructive is the arming gate reserved for rules that change state.
|
||||
// DOM XSS is read-only navigation and does not consult it, but the field is
|
||||
// the seam a future rule's Plan refusal hangs on.
|
||||
AllowDestructive bool
|
||||
|
||||
BudgetMs int
|
||||
}
|
||||
|
||||
// RuleDeps is everything a rule might need to originate traffic or drive a
|
||||
// browser. A rule takes what it uses and ignores the rest.
|
||||
type RuleDeps struct {
|
||||
ProxyAddr string
|
||||
CA *cert.CA
|
||||
Store *proxy.Store
|
||||
Scope *proxy.Scope
|
||||
DataDir string
|
||||
}
|
||||
|
||||
// Reporter is how a rule reports progress and results back to the run. Both
|
||||
// methods are safe to call from the rule's own goroutines.
|
||||
type Reporter interface {
|
||||
// Finding files a confirmed result into the shared detect store.
|
||||
Finding(detect.Finding)
|
||||
// Progress advances the run's completed count by delta units of work.
|
||||
Progress(delta int)
|
||||
}
|
||||
|
||||
// Rule is one active check. It owns its own traversal and resources: the DOM XSS
|
||||
// rule launches one browser and drives every URL through it, tearing it down
|
||||
// when Run returns.
|
||||
type Rule interface {
|
||||
ID() string
|
||||
Name() string
|
||||
Category() detect.Category
|
||||
Run(ctx context.Context, t Target, deps RuleDeps, rep Reporter) error
|
||||
}
|
||||
|
||||
// Registry holds the rules a host process offers. It is built and owned by the
|
||||
// caller (the API server), not a package global.
|
||||
//
|
||||
// A rule's enabled-state is stored as the DISABLED set (an absent id is enabled),
|
||||
// so a newly-registered rule defaults on with no migration and the project file
|
||||
// persists only the exceptions. The enabled-state is per-project session state:
|
||||
// applyProjectConfig sets it on a project switch and buildProjectConfig reads it.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
rules map[string]Rule
|
||||
order []string
|
||||
disabled map[string]bool
|
||||
}
|
||||
|
||||
// NewRegistry creates an empty registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{rules: make(map[string]Rule), disabled: make(map[string]bool)}
|
||||
}
|
||||
|
||||
// Register adds a rule, keeping registration order for listing.
|
||||
func (r *Registry) Register(rule Rule) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.rules[rule.ID()]; !ok {
|
||||
r.order = append(r.order, rule.ID())
|
||||
}
|
||||
r.rules[rule.ID()] = rule
|
||||
}
|
||||
|
||||
// Get returns a rule by ID, or nil.
|
||||
func (r *Registry) Get(id string) Rule {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.rules[id]
|
||||
}
|
||||
|
||||
// All returns every registered rule in registration order.
|
||||
func (r *Registry) All() []Rule {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]Rule, 0, len(r.order))
|
||||
for _, id := range r.order {
|
||||
out = append(out, r.rules[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsEnabled reports whether a rule runs when no explicit selection is made.
|
||||
func (r *Registry) IsEnabled(id string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return !r.disabled[id]
|
||||
}
|
||||
|
||||
// SetEnabled toggles a rule's enabled-state.
|
||||
func (r *Registry) SetEnabled(id string, on bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if on {
|
||||
delete(r.disabled, id)
|
||||
} else {
|
||||
r.disabled[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled returns the disabled rule IDs, sorted — the set the project file
|
||||
// persists.
|
||||
func (r *Registry) Disabled() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]string, 0, len(r.disabled))
|
||||
for id := range r.disabled {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetDisabled replaces the disabled set, for a project restore. A nil slice
|
||||
// clears it, so a project with no setting runs every rule.
|
||||
func (r *Registry) SetDisabled(ids []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.disabled = make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
r.disabled[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve returns the rules a config selects, in registration order. An empty
|
||||
// selection means every ENABLED rule; an explicit selection returns the named
|
||||
// registered rules regardless of enabled-state, so an operator choosing a rule
|
||||
// overrides its global toggle.
|
||||
func (r *Registry) Resolve(ids []string) []Rule {
|
||||
if len(ids) == 0 {
|
||||
out := make([]Rule, 0, len(r.order))
|
||||
for _, rule := range r.All() {
|
||||
if r.IsEnabled(rule.ID()) {
|
||||
out = append(out, rule)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
want := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
want[id] = true
|
||||
}
|
||||
out := make([]Rule, 0, len(ids))
|
||||
for _, rule := range r.All() {
|
||||
if want[rule.ID()] {
|
||||
out = append(out, rule)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clampInt(v, def, lo, hi int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Package domxss is the first internal/activescan rule: it confirms DOM-based XSS
|
||||
// by driving a headless browser over CDP.
|
||||
//
|
||||
// For each target URL it plants a distinct canary token in each controllable DOM
|
||||
// source — the URL fragment, a query value and window.name — then navigates to
|
||||
// the URL. A monitor shim (monitor.js), injected before any page script runs and
|
||||
// so unaffected by the page's CSP, wraps the dangerous sinks (innerHTML, eval,
|
||||
// document.write, setAttribute, jQuery.html, …). When a canary reaches a sink the
|
||||
// shim reports which one and from which source over a CDP binding, and that
|
||||
// becomes a high-confidence detect.Finding.
|
||||
//
|
||||
// Per-source canaries make attribution exact: because the hash, query and name
|
||||
// tokens differ, a sink hit names the precise source that reached it rather than
|
||||
// guessing from whichever source happened to hold the payload.
|
||||
package domxss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/browser"
|
||||
"github.com/BishopFox/joro/internal/cdp"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
//go:embed monitor.js
|
||||
var monitorJS string
|
||||
|
||||
const (
|
||||
ruleID = "domxss"
|
||||
bindingName = "__joroDomXss"
|
||||
findingRule = "activescan.domxss"
|
||||
|
||||
connectTimeout = 20 * time.Second
|
||||
perURLTimeout = 15 * time.Second
|
||||
settleDelay = 1200 * time.Millisecond
|
||||
)
|
||||
|
||||
// Rule implements activescan.Rule.
|
||||
type Rule struct{}
|
||||
|
||||
// New returns the DOM XSS rule.
|
||||
func New() *Rule { return &Rule{} }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "DOM-based XSS" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategoryDOMXSS }
|
||||
|
||||
// hit is one sink report from the injected monitor.
|
||||
type hit struct {
|
||||
Sink string `json:"sink"`
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
Token string `json:"token"`
|
||||
Value string `json:"value"`
|
||||
URL string `json:"url"`
|
||||
Stack string `json:"stack"`
|
||||
}
|
||||
|
||||
// collector gathers monitor reports as they arrive on the CDP read loop.
|
||||
type collector struct {
|
||||
mu sync.Mutex
|
||||
hits []hit
|
||||
}
|
||||
|
||||
func (c *collector) add(h hit) {
|
||||
c.mu.Lock()
|
||||
c.hits = append(c.hits, h)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// drain returns every hit received since it was last called.
|
||||
func (c *collector) drain() []hit {
|
||||
c.mu.Lock()
|
||||
out := c.hits
|
||||
c.hits = nil
|
||||
c.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// Run launches a dedicated headless browser, injects the monitor, and navigates
|
||||
// each target URL with canaries, reporting confirmed flows.
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
if deps.CA == nil {
|
||||
return fmt.Errorf("no CA available for the scan browser")
|
||||
}
|
||||
path, _, ok := browser.Find()
|
||||
if !ok {
|
||||
return fmt.Errorf("no Chromium-family browser found to drive the scan")
|
||||
}
|
||||
|
||||
profileDir := filepath.Join(deps.DataDir, "browser-profiles", "__scan")
|
||||
sess, err := browser.LaunchControlled(browser.LaunchOptions{
|
||||
BrowserPath: path,
|
||||
ProxyAddr: deps.ProxyAddr,
|
||||
SPKIFingerprint: browser.SPKIFingerprint(deps.CA.Cert),
|
||||
ProfileDir: profileDir,
|
||||
Headless: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch scan browser: %w", err)
|
||||
}
|
||||
defer sess.Kill(true)
|
||||
|
||||
client, err := cdp.Connect(ctx, sess.DebugPort, connectTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
col := &collector{}
|
||||
loadCh := make(chan struct{}, 1)
|
||||
client.On("Page.loadEventFired", func(json.RawMessage) {
|
||||
select {
|
||||
case loadCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
client.On("Runtime.bindingCalled", func(params json.RawMessage) {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil || p.Name != bindingName {
|
||||
return
|
||||
}
|
||||
var h hit
|
||||
if err := json.Unmarshal([]byte(p.Payload), &h); err == nil {
|
||||
col.add(h)
|
||||
}
|
||||
})
|
||||
|
||||
if _, err := client.Send(ctx, "Page.enable", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.Send(ctx, "Runtime.enable", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.Send(ctx, "Runtime.addBinding", map[string]any{"name": bindingName}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.Send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": monitorJS}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, tu := range t.URLs {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
scanURL(ctx, client, col, loadCh, t.Host, tu.URL, rep)
|
||||
rep.Progress(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanURL plants canaries, navigates, waits for load plus a settle delay, and
|
||||
// files any confirmed hits.
|
||||
func scanURL(ctx context.Context, client *cdp.Client, col *collector, loadCh chan struct{}, host, rawURL string, rep activescan.Reporter) {
|
||||
navURL, nameCanary := buildCanaryURL(rawURL)
|
||||
if navURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// window.name persists across the navigation, so set it first.
|
||||
_, _ = client.Send(ctx, "Runtime.evaluate", map[string]any{
|
||||
"expression": "window.name=" + jsString(nameCanary),
|
||||
"returnByValue": true,
|
||||
})
|
||||
|
||||
// Drop any stale load signal from a previous navigation.
|
||||
select {
|
||||
case <-loadCh:
|
||||
default:
|
||||
}
|
||||
|
||||
urlCtx, cancel := context.WithTimeout(ctx, perURLTimeout)
|
||||
defer cancel()
|
||||
if _, err := client.Send(urlCtx, "Page.navigate", map[string]any{"url": navURL}); err != nil {
|
||||
return
|
||||
}
|
||||
// Wait for the load event (or the per-URL timeout), then give async sinks a
|
||||
// moment after load to fire.
|
||||
select {
|
||||
case <-urlCtx.Done():
|
||||
return
|
||||
case <-loadCh:
|
||||
}
|
||||
select {
|
||||
case <-urlCtx.Done():
|
||||
case <-time.After(settleDelay):
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, h := range col.drain() {
|
||||
key := h.Source + "|" + h.Sink
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
rep.Finding(finding(host, navURL, h))
|
||||
}
|
||||
}
|
||||
|
||||
// buildCanaryURL returns the URL to navigate, carrying a hash and query canary,
|
||||
// plus the distinct window.name canary to set beforehand. It returns "" if the
|
||||
// URL cannot be parsed.
|
||||
func buildCanaryURL(rawURL string) (navURL, nameCanary string) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", ""
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("joroqp", canary("q"))
|
||||
u.RawQuery = q.Encode()
|
||||
u.Fragment = canary("h")
|
||||
return u.String(), canary("n")
|
||||
}
|
||||
|
||||
// canary mints a per-source token the monitor recognizes: __joroDX_<channel>_<hex>.
|
||||
func canary(channel string) string {
|
||||
var b [6]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "__joroDX_" + channel + "_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// finding builds the detect.Finding for a confirmed sink hit.
|
||||
func finding(host, navURL string, h hit) detect.Finding {
|
||||
now := time.Now()
|
||||
path := navURL
|
||||
if u, err := url.Parse(navURL); err == nil {
|
||||
path = u.Path
|
||||
}
|
||||
evidence := fmt.Sprintf("%s reached %s (via %s)", h.Source, h.Sink, h.Channel)
|
||||
if h.Stack != "" {
|
||||
evidence += "\n" + firstLines(h.Stack, 6)
|
||||
}
|
||||
return detect.Finding{
|
||||
ID: detect.FindingID(findingRule, host, h.Source+"|"+h.Sink+"|"+path),
|
||||
RuleID: findingRule,
|
||||
RuleName: "DOM-based XSS",
|
||||
Category: detect.CategoryDOMXSS,
|
||||
Severity: detect.SeverityHigh,
|
||||
Confidence: detect.ConfidenceHigh,
|
||||
Target: detect.TargetMessage,
|
||||
Host: host,
|
||||
Method: "GET",
|
||||
URL: navURL,
|
||||
Detail: h.Source + " reaches " + h.Sink,
|
||||
Evidence: evidence,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
EvidenceOffset: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// jsString encodes s as a JavaScript string literal for Runtime.evaluate.
|
||||
func jsString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// firstLines returns at most n lines of s.
|
||||
func firstLines(s string, n int) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[:n]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// DOM XSS sink monitor, injected by internal/activescan/domxss via CDP's
|
||||
// Page.addScriptToEvaluateOnNewDocument, so it runs before any page script and is
|
||||
// not subject to the page's CSP.
|
||||
//
|
||||
// It wraps the dangerous DOM sinks and, whenever one receives a value containing
|
||||
// a Joro canary token, reports the sink, the source channel the canary came from
|
||||
// (hash / query / window.name) and a stack trace back to Go through the
|
||||
// __joroDomXss binding. It never throws into the page.
|
||||
(function () {
|
||||
try {
|
||||
var MARK = "__joroDX_";
|
||||
var reported = {};
|
||||
|
||||
function channelSource(ch) {
|
||||
if (ch === "h") return "location.hash";
|
||||
if (ch === "q") return "location.search";
|
||||
if (ch === "n") return "window.name";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function scan(val, sink) {
|
||||
if (val == null) return;
|
||||
var s;
|
||||
try { s = String(val); } catch (e) { return; }
|
||||
if (s.indexOf(MARK) < 0) return;
|
||||
var m = s.match(/__joroDX_([hqn])_([0-9a-f]+)/);
|
||||
if (!m) return;
|
||||
var channel = m[1];
|
||||
var token = m[0];
|
||||
var key = sink + "|" + channel + "|" + token;
|
||||
if (reported[key]) return;
|
||||
reported[key] = true;
|
||||
|
||||
var payload = {
|
||||
sink: sink,
|
||||
source: channelSource(channel),
|
||||
channel: channel,
|
||||
token: token,
|
||||
value: s.length > 2000 ? s.slice(0, 2000) : s,
|
||||
url: "",
|
||||
stack: ""
|
||||
};
|
||||
try { payload.url = location.href; } catch (e) {}
|
||||
try { payload.stack = (new Error()).stack || ""; } catch (e) {}
|
||||
try { window.__joroDomXss(JSON.stringify(payload)); } catch (e) {}
|
||||
}
|
||||
|
||||
function hookProp(proto, prop, sink) {
|
||||
try {
|
||||
var d = Object.getOwnPropertyDescriptor(proto, prop);
|
||||
if (!d || !d.set) return;
|
||||
var origSet = d.set;
|
||||
Object.defineProperty(proto, prop, {
|
||||
configurable: true,
|
||||
enumerable: d.enumerable,
|
||||
get: d.get,
|
||||
set: function (v) { scan(v, sink); return origSet.call(this, v); }
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
hookProp(Element.prototype, "innerHTML", "Element.innerHTML");
|
||||
hookProp(Element.prototype, "outerHTML", "Element.outerHTML");
|
||||
|
||||
try {
|
||||
var iah = Element.prototype.insertAdjacentHTML;
|
||||
Element.prototype.insertAdjacentHTML = function (pos, html) {
|
||||
scan(html, "insertAdjacentHTML");
|
||||
return iah.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
function hookDocWrite(name) {
|
||||
try {
|
||||
var orig = document[name];
|
||||
document[name] = function () {
|
||||
for (var i = 0; i < arguments.length; i++) scan(arguments[i], "document." + name);
|
||||
return orig.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
}
|
||||
hookDocWrite("write");
|
||||
hookDocWrite("writeln");
|
||||
|
||||
try {
|
||||
var _eval = window.eval;
|
||||
window.eval = function (code) { scan(code, "eval"); return _eval(code); };
|
||||
} catch (e) {}
|
||||
try {
|
||||
var _Func = window.Function;
|
||||
window.Function = function () {
|
||||
for (var i = 0; i < arguments.length; i++) scan(arguments[i], "Function");
|
||||
return _Func.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
function hookTimer(name) {
|
||||
try {
|
||||
var orig = window[name];
|
||||
window[name] = function (fn) {
|
||||
if (typeof fn === "string") scan(fn, name + "(string)");
|
||||
return orig.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
}
|
||||
hookTimer("setTimeout");
|
||||
hookTimer("setInterval");
|
||||
|
||||
try {
|
||||
var _setAttr = Element.prototype.setAttribute;
|
||||
Element.prototype.setAttribute = function (name, value) {
|
||||
try {
|
||||
var n = ("" + name).toLowerCase();
|
||||
if (n === "src" || n === "href" || n === "data" || n === "action" ||
|
||||
n === "formaction" || n.indexOf("on") === 0) {
|
||||
scan(value, "setAttribute(" + n + ")");
|
||||
}
|
||||
} catch (e) {}
|
||||
return _setAttr.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
function hookJQuery() {
|
||||
try {
|
||||
var jq = window.jQuery;
|
||||
if (!jq || !jq.fn) return;
|
||||
["html", "append", "prepend", "after", "before", "replaceWith"].forEach(function (mth) {
|
||||
var orig = jq.fn[mth];
|
||||
if (typeof orig !== "function") return;
|
||||
jq.fn[mth] = function () {
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
if (typeof arguments[i] === "string") scan(arguments[i], "jQuery." + mth);
|
||||
}
|
||||
return orig.apply(this, arguments);
|
||||
};
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
hookJQuery();
|
||||
try { document.addEventListener("DOMContentLoaded", hookJQuery); } catch (e) {}
|
||||
} catch (e) {}
|
||||
})();
|
||||
@@ -0,0 +1,233 @@
|
||||
package activescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
)
|
||||
|
||||
// Run is one scan execution.
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"`
|
||||
Origin string `json:"origin"`
|
||||
Scope string `json:"scope"` // "host" | "request"
|
||||
Rules []string `json:"rules"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Total int `json:"total"`
|
||||
|
||||
Config Config `json:"-"`
|
||||
|
||||
urls []TargetURL // the scope-filtered URLs this run exercises
|
||||
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
completed int
|
||||
errors int
|
||||
findings int
|
||||
errMsgs []string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewRun creates a running scan with its totals set. total is the number of work
|
||||
// units the run expects to complete: one per (rule, URL).
|
||||
func NewRun(id string, cfg Config, t Target, ruleIDs []string, total int) *Run {
|
||||
return &Run{
|
||||
ID: id,
|
||||
Host: t.Host,
|
||||
Origin: t.Origin,
|
||||
Scope: cfg.Scope,
|
||||
Rules: ruleIDs,
|
||||
CreatedAt: time.Now(),
|
||||
Total: total,
|
||||
Config: cfg,
|
||||
urls: t.URLs,
|
||||
status: StatusRunning,
|
||||
}
|
||||
}
|
||||
|
||||
// Status reports the run's state.
|
||||
func (r *Run) Status() Status {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.status
|
||||
}
|
||||
|
||||
// Counts reports progress.
|
||||
func (r *Run) Counts() (completed, errors, findings int) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.completed, r.errors, r.findings
|
||||
}
|
||||
|
||||
// Errors returns the rule-level failures observed so far.
|
||||
func (r *Run) Errors() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return append([]string{}, r.errMsgs...)
|
||||
}
|
||||
|
||||
func (r *Run) addProgress(delta int) {
|
||||
r.mu.Lock()
|
||||
r.completed += delta
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Run) addFinding() {
|
||||
r.mu.Lock()
|
||||
r.findings++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Run) addError(msg string) {
|
||||
r.mu.Lock()
|
||||
r.errors++
|
||||
r.errMsgs = append(r.errMsgs, msg)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Run) finish(s Status) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.status == StatusRunning {
|
||||
r.status = s
|
||||
}
|
||||
}
|
||||
|
||||
// Stop cancels a running run.
|
||||
func (r *Run) Stop() bool {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancel
|
||||
running := r.status == StatusRunning
|
||||
if running {
|
||||
r.status = StatusStopped
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return running
|
||||
}
|
||||
|
||||
func (r *Run) setCancel(cancel context.CancelFunc) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.cancel = cancel
|
||||
}
|
||||
|
||||
// Deps is what a run needs from the host process.
|
||||
type Deps struct {
|
||||
// Broadcast is the hub's channel, carrying event.WSEvent values.
|
||||
Broadcast chan<- any
|
||||
// Findings is the shared detect store a rule's results are filed into.
|
||||
Findings *detect.Store
|
||||
// Notify pushes the aggregate detect.summary (s.broadcastDetectSummary).
|
||||
Notify func()
|
||||
// Registry resolves the run's rule IDs to Rule implementations.
|
||||
Registry *Registry
|
||||
// RuleDeps is forwarded to each rule.
|
||||
RuleDeps RuleDeps
|
||||
}
|
||||
|
||||
// Plan reports how many work units a scan will run and refuses one that cannot
|
||||
// run, before any browser launches or byte goes on the wire.
|
||||
func Plan(cfg Config, t Target, rules []Rule) (int, error) {
|
||||
if len(t.URLs) == 0 {
|
||||
return 0, fmt.Errorf("no in-scope URLs to scan")
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return 0, fmt.Errorf("no active-scan rules selected")
|
||||
}
|
||||
return len(t.URLs) * len(rules), nil
|
||||
}
|
||||
|
||||
// Execute runs every selected rule against the target, sequentially.
|
||||
func Execute(ctx context.Context, run *Run, d Deps) {
|
||||
budget := time.Duration(clampInt(run.Config.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
|
||||
runCtx, cancel := context.WithTimeout(ctx, budget)
|
||||
defer cancel()
|
||||
run.setCancel(cancel)
|
||||
|
||||
started := time.Now()
|
||||
broadcast(d, "activescan.started", map[string]any{
|
||||
"runId": run.ID, "host": run.Host, "origin": run.Origin,
|
||||
"scope": run.Scope, "rules": run.Rules, "total": run.Total,
|
||||
})
|
||||
|
||||
rep := &reporter{run: run, d: d}
|
||||
rules := d.Registry.Resolve(run.Config.Rules)
|
||||
|
||||
status := StatusComplete
|
||||
for _, rule := range rules {
|
||||
if runCtx.Err() != nil {
|
||||
status = StatusStopped
|
||||
break
|
||||
}
|
||||
if err := rule.Run(runCtx, Target{
|
||||
Scheme: schemeOf(run.Origin), Host: run.Host, Origin: run.Origin, URLs: run.urls,
|
||||
}, d.RuleDeps, rep); err != nil {
|
||||
run.addError(fmt.Sprintf("%s: %v", rule.Name(), err))
|
||||
}
|
||||
}
|
||||
if runCtx.Err() != nil {
|
||||
status = StatusStopped
|
||||
}
|
||||
run.finish(status)
|
||||
|
||||
completed, errs, findings := run.Counts()
|
||||
broadcast(d, "activescan.complete", map[string]any{
|
||||
"runId": run.ID, "status": string(run.Status()),
|
||||
"completed": completed, "errors": errs, "findings": findings,
|
||||
"durationMs": time.Since(started).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// reporter adapts a run and its deps to the Reporter a rule writes through.
|
||||
type reporter struct {
|
||||
run *Run
|
||||
d Deps
|
||||
}
|
||||
|
||||
func (r *reporter) Progress(delta int) {
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
r.run.addProgress(delta)
|
||||
completed, _, findings := r.run.Counts()
|
||||
broadcast(r.d, "activescan.progress", map[string]any{
|
||||
"runId": r.run.ID, "scanned": completed, "total": r.run.Total, "findings": findings,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *reporter) Finding(f detect.Finding) {
|
||||
if r.d.Findings == nil {
|
||||
return
|
||||
}
|
||||
saved, isNew := r.d.Findings.Upsert(f)
|
||||
r.run.addFinding()
|
||||
broadcast(r.d, "detect.finding", map[string]any{"finding": saved, "isNew": isNew})
|
||||
if r.d.Notify != nil {
|
||||
r.d.Notify()
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast sends blocking, from the run's own goroutine, like chainrun and
|
||||
// fuzzer: Hub.Subscribe fans out without blocking, so a slow subscriber cannot
|
||||
// stall the run, and blocking keeps the client's progress agreeing with the run.
|
||||
func broadcast(d Deps, typ string, data map[string]any) {
|
||||
if d.Broadcast == nil {
|
||||
return
|
||||
}
|
||||
d.Broadcast <- event.WSEvent{Type: typ, Data: data}
|
||||
}
|
||||
|
||||
func schemeOf(origin string) string {
|
||||
if len(origin) >= 8 && origin[:8] == "https://" {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package activescan
|
||||
|
||||
import "sync"
|
||||
|
||||
// Store holds runs in memory, bounded like chainrun.Store. Runs are session
|
||||
// state and never reach the project file.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
runs []*Run
|
||||
}
|
||||
|
||||
// NewStore creates an empty run store.
|
||||
func NewStore() *Store { return &Store{} }
|
||||
|
||||
// Get returns a run by ID, or nil.
|
||||
func (s *Store) Get(id string) *Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.runs {
|
||||
if r.ID == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every run, newest first.
|
||||
func (s *Store) List() []*Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Run, len(s.runs))
|
||||
for i, r := range s.runs {
|
||||
out[len(s.runs)-1-i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Running returns the run currently in flight, or nil.
|
||||
func (s *Store) Running() *Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.runs {
|
||||
if r.Status() == StatusRunning {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddIfIdle adds a run only if none is in flight, returning the run that blocked
|
||||
// it or nil on success.
|
||||
//
|
||||
// One lock for the test and the insert: a Running check then a separate Add
|
||||
// leaves a gap two requests both pass, which is the concurrent scan the
|
||||
// single-run rule exists to prevent.
|
||||
func (s *Store) AddIfIdle(r *Run) *Run {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, old := range s.runs {
|
||||
if old.Status() == StatusRunning {
|
||||
return old
|
||||
}
|
||||
}
|
||||
if len(s.runs) >= MaxRuns {
|
||||
for i, old := range s.runs {
|
||||
if old.Status() != StatusRunning {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.runs = append(s.runs, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a run, reporting whether it was found.
|
||||
func (s *Store) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, r := range s.runs {
|
||||
if r.ID == id {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear stops and drops every run, for a project switch.
|
||||
func (s *Store) Clear() {
|
||||
s.mu.Lock()
|
||||
runs := s.runs
|
||||
s.runs = nil
|
||||
s.mu.Unlock()
|
||||
for _, r := range runs {
|
||||
r.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// activeScanAvailable guards the active-scan routes, which exist only in proxy
|
||||
// mode. A JSON 404 rather than an unregistered route, so the SPA catch-all does
|
||||
// not answer with 200 + HTML.
|
||||
func (s *APIServer) activeScanAvailable(w http.ResponseWriter) bool {
|
||||
if s.activeScans == nil || s.activeScanRules == nil {
|
||||
writeError(w, http.StatusNotFound, "active scanning is not available in this mode")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// activeScanRequest starts a scan of a host or a single request.
|
||||
type activeScanRequest struct {
|
||||
// Scope is "host" (every captured GET endpoint for Origin) or "request" (one
|
||||
// URL, from RequestID or URL).
|
||||
Scope string `json:"scope"`
|
||||
Origin string `json:"origin,omitempty"` // scheme://host[:port], for a host scan
|
||||
URL string `json:"url,omitempty"` // a single URL, e.g. from Manipulate
|
||||
RequestID string `json:"requestId,omitempty"` // a captured request to scan
|
||||
Rules []string `json:"rules,omitempty"` // rule IDs; empty = all
|
||||
BudgetMs int `json:"budgetMs,omitempty"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanStart(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
var req activeScanRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := s.resolveScanTarget(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Scope filter, never a rule-count check: InScope returns true when scope
|
||||
// filtering is disabled, so with scope off nothing is rejected and the scan
|
||||
// covers every target. A host scan silently drops out-of-scope endpoints; a
|
||||
// single-request scan whose one URL is out of scope is refused outright so the
|
||||
// operator is not left with a silent, empty run.
|
||||
inScope, skipped := s.filterInScope(target.URLs)
|
||||
if len(inScope) == 0 {
|
||||
if req.Scope == "request" {
|
||||
writeError(w, http.StatusForbidden, "target is out of scope; adjust scope rules or disable scope filtering")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "no in-scope GET endpoints to scan for this host")
|
||||
return
|
||||
}
|
||||
target.URLs = inScope
|
||||
|
||||
rules := s.activeScanRules.Resolve(req.Rules)
|
||||
cfg := activescan.Config{
|
||||
Scope: req.Scope,
|
||||
Rules: req.Rules,
|
||||
BudgetMs: req.BudgetMs,
|
||||
}
|
||||
total, err := activescan.Plan(cfg, target, rules)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ruleIDs := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
ruleIDs = append(ruleIDs, rule.ID())
|
||||
}
|
||||
|
||||
// One at a time, globally. A DOM XSS scan drives a single browser and a future
|
||||
// state-changing rule would race the target's own state; the claim and the
|
||||
// insert are one operation so two requests cannot both pass.
|
||||
run := activescan.NewRun(proxy.GenerateID(), cfg, target, ruleIDs, total)
|
||||
if running := s.activeScans.AddIfIdle(run); running != nil {
|
||||
writeError(w, http.StatusConflict,
|
||||
fmt.Sprintf("a scan of %q is already in flight; stop it before starting another", running.Origin))
|
||||
return
|
||||
}
|
||||
|
||||
deps := activescan.Deps{
|
||||
Broadcast: s.hub.Broadcast(),
|
||||
Findings: s.detectFindings,
|
||||
Notify: s.broadcastDetectSummary,
|
||||
Registry: s.activeScanRules,
|
||||
RuleDeps: activescan.RuleDeps{
|
||||
ProxyAddr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.ProxyPort),
|
||||
CA: s.ca,
|
||||
Store: s.store,
|
||||
Scope: s.scope,
|
||||
DataDir: s.cfg.DataDir,
|
||||
},
|
||||
}
|
||||
// context.Background, not the request's: a scan outlives the HTTP call.
|
||||
go activescan.Execute(context.Background(), run, deps)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"runId": run.ID, "total": total, "rules": ruleIDs,
|
||||
"urls": len(target.URLs), "skipped": skipped,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveScanTarget turns a request into the concrete URLs to scan.
|
||||
func (s *APIServer) resolveScanTarget(req activeScanRequest) (activescan.Target, error) {
|
||||
switch req.Scope {
|
||||
case "host":
|
||||
if req.Origin == "" {
|
||||
return activescan.Target{}, fmt.Errorf("origin is required for a host scan")
|
||||
}
|
||||
return s.hostTarget(req.Origin)
|
||||
case "request":
|
||||
return s.requestTarget(req)
|
||||
default:
|
||||
return activescan.Target{}, fmt.Errorf("scope must be \"host\" or \"request\"")
|
||||
}
|
||||
}
|
||||
|
||||
// hostTarget collects a host's unique GET URLs from the capture store.
|
||||
func (s *APIServer) hostTarget(origin string) (activescan.Target, error) {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return activescan.Target{}, fmt.Errorf("invalid origin")
|
||||
}
|
||||
reqs := s.store.NodeRequests(origin, "", false, proxy.RequestFilter{})
|
||||
seen := map[string]bool{}
|
||||
var urls []activescan.TargetURL
|
||||
for _, cr := range reqs {
|
||||
if !strings.EqualFold(cr.Method, "GET") || seen[cr.URL] {
|
||||
continue
|
||||
}
|
||||
seen[cr.URL] = true
|
||||
urls = append(urls, activescan.TargetURL{URL: cr.URL, Method: "GET", RequestID: cr.ID})
|
||||
if len(urls) >= activescan.MaxURLs {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return activescan.Target{}, fmt.Errorf("no captured GET endpoints for %s", origin)
|
||||
}
|
||||
return activescan.Target{Scheme: u.Scheme, Host: u.Host, Origin: origin, URLs: urls}, nil
|
||||
}
|
||||
|
||||
// requestTarget builds a single-URL target from a captured request or a raw URL.
|
||||
func (s *APIServer) requestTarget(req activeScanRequest) (activescan.Target, error) {
|
||||
rawURL := strings.TrimSpace(req.URL)
|
||||
requestID := ""
|
||||
if req.RequestID != "" {
|
||||
cr := s.store.Get(req.RequestID)
|
||||
if cr == nil {
|
||||
return activescan.Target{}, fmt.Errorf("no such request")
|
||||
}
|
||||
rawURL = cr.URL
|
||||
requestID = cr.ID
|
||||
}
|
||||
if rawURL == "" {
|
||||
return activescan.Target{}, fmt.Errorf("a request scan needs a requestId or url")
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" || u.Scheme == "" {
|
||||
return activescan.Target{}, fmt.Errorf("invalid url")
|
||||
}
|
||||
return activescan.Target{
|
||||
Scheme: u.Scheme,
|
||||
Host: u.Host,
|
||||
Origin: u.Scheme + "://" + u.Host,
|
||||
URLs: []activescan.TargetURL{{URL: rawURL, Method: "GET", RequestID: requestID}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterInScope keeps only URLs that pass scope, returning the kept set and the
|
||||
// number dropped. With scope filtering disabled, InScope is true for all.
|
||||
func (s *APIServer) filterInScope(urls []activescan.TargetURL) (kept []activescan.TargetURL, skipped int) {
|
||||
for _, tu := range urls {
|
||||
u, err := url.Parse(tu.URL)
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if s.scope.InScope(u.Host, "GET", u.Path) {
|
||||
kept = append(kept, tu)
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
return kept, skipped
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanRules(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
rules := s.activeScanRules.All()
|
||||
out := make([]map[string]any, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
out = append(out, map[string]any{
|
||||
"id": rule.ID(), "name": rule.Name(), "category": string(rule.Category()),
|
||||
"enabled": s.activeScanRules.IsEnabled(rule.ID()),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rules": out})
|
||||
}
|
||||
|
||||
// handleSetActiveScanRuleEnabled toggles whether a rule runs when a scan is
|
||||
// started without an explicit rule selection. The toggle persists with the
|
||||
// project, so it marks the config dirty. Mirrors handleSetDetectRuleEnabled.
|
||||
func (s *APIServer) handleSetActiveScanRuleEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if s.activeScanRules.Get(id) == nil {
|
||||
writeError(w, http.StatusNotFound, "no such rule")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.activeScanRules.SetEnabled(id, body.Enabled)
|
||||
// The toggle is picked up by the next autosave tick, which compares
|
||||
// projectSignature (now carrying the disabled set); no explicit save here.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanListRuns(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
runs := s.activeScans.List()
|
||||
out := make([]map[string]any, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
out = append(out, activeScanHead(run))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
|
||||
}
|
||||
|
||||
func activeScanHead(run *activescan.Run) map[string]any {
|
||||
completed, errs, findings := run.Counts()
|
||||
return map[string]any{
|
||||
"id": run.ID, "host": run.Host, "origin": run.Origin, "scope": run.Scope,
|
||||
"rules": run.Rules, "status": string(run.Status()), "total": run.Total,
|
||||
"completed": completed, "errors": errs, "findings": findings,
|
||||
"createdAt": run.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanGetRun(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
run := s.activeScans.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
body := activeScanHead(run)
|
||||
body["errorMessages"] = run.Errors()
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanStopRun(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
run := s.activeScans.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if !run.Stop() {
|
||||
writeError(w, http.StatusConflict, "run is not running")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanDeleteRun(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
run := s.activeScans.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if run.Status() == activescan.StatusRunning {
|
||||
writeError(w, http.StatusConflict, "stop the run before deleting it")
|
||||
return
|
||||
}
|
||||
s.activeScans.Delete(run.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/browser"
|
||||
@@ -226,6 +227,12 @@ type projectConfigFile struct {
|
||||
// rebuilds it, so persisting it would store a second copy of captured
|
||||
// traffic that a project load could not keep in step with the first.
|
||||
EchoConfig *echo.Config `json:"echoConfig,omitempty"`
|
||||
|
||||
// ActiveScanDisabledRules lists the active-scan rule IDs the operator turned
|
||||
// off (schema v10). Only the exceptions travel, so a project that never
|
||||
// touched them decodes to nil and runs every rule — no normalize gate needed,
|
||||
// for the same reason EchoConfig/Chains above need none.
|
||||
ActiveScanDisabledRules []string `json:"activeScanDisabledRules,omitempty"`
|
||||
}
|
||||
|
||||
// encodePluginStates base64-encodes each blob for transport inside a JSON
|
||||
@@ -362,12 +369,18 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
echoCfg = &c
|
||||
}
|
||||
|
||||
var activeScanDisabled []string
|
||||
if s.activeScanRules != nil {
|
||||
activeScanDisabled = s.activeScanRules.Disabled()
|
||||
}
|
||||
|
||||
return projectConfigFile{
|
||||
// v9 added EchoConfig; v8 added Chains; v7 added AutomationStates. None
|
||||
// needs a normalizeProjectConfig gate: all decode to nil when absent, which
|
||||
// is the correct default — a backfill exists only where the zero value would
|
||||
// be wrong, as it was for autoSave and detectEnabled.
|
||||
Version: 9,
|
||||
// v10 added ActiveScanDisabledRules; v9 added EchoConfig; v8 added Chains;
|
||||
// v7 added AutomationStates. None needs a normalizeProjectConfig gate: all
|
||||
// decode to nil when absent, which is the correct default — a backfill
|
||||
// exists only where the zero value would be wrong, as it was for autoSave
|
||||
// and detectEnabled.
|
||||
Version: 10,
|
||||
AutoSave: autoSave,
|
||||
SaveHistory: saveHistory,
|
||||
ListenerURL: listenerURL,
|
||||
@@ -399,8 +412,9 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
// state the first time anyone else saves.
|
||||
AutomationStates: encodePluginStates(mergePluginStates(automationGhost, automationFresh)),
|
||||
|
||||
Chains: s.chainStore.List(),
|
||||
EchoConfig: echoCfg,
|
||||
Chains: s.chainStore.List(),
|
||||
EchoConfig: echoCfg,
|
||||
ActiveScanDisabledRules: activeScanDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,11 +608,17 @@ func (s *APIServer) liveStateSignature() string {
|
||||
echoSig = fmt.Sprintf("/ec%v.%d.%d.%d", c.Enabled, c.MinValueLen,
|
||||
c.TransformDepth, c.MaxBodyScanBytes)
|
||||
}
|
||||
// Only the active-rule toggles persist, so only they are fingerprinted — not
|
||||
// the run list, which is session state.
|
||||
activeScanSig := ""
|
||||
if s.activeScanRules != nil {
|
||||
activeScanSig = "/as" + strings.Join(s.activeScanRules.Disabled(), ",")
|
||||
}
|
||||
return fmt.Sprintf("r%d/s%d/n%d/u%d/h%d/sc%s/rp%d/cd%d/no%d/lc%d.%d",
|
||||
reqCount, lastSeq, noteCount, maxNoteUpdate, hlCount,
|
||||
scopeSignature(s.scope.Rules()), len(s.replace.Rules()),
|
||||
len(s.customData.Items()), len(s.noise.Patterns()), chainCount, chainRev) +
|
||||
s.detectSignature() + s.automationSignature() + echoSig
|
||||
s.detectSignature() + s.automationSignature() + echoSig + activeScanSig
|
||||
}
|
||||
|
||||
// saveProject snapshots live state to the named project's .joro (respecting its
|
||||
@@ -689,6 +709,9 @@ func (s *APIServer) resetLiveProjectState() {
|
||||
if s.chainRuns != nil {
|
||||
s.chainRuns.Clear()
|
||||
}
|
||||
if s.activeScans != nil {
|
||||
s.activeScans.Clear()
|
||||
}
|
||||
// The reflection map describes the previous engagement's traffic, so it goes
|
||||
// with it; the settings return to their defaults alongside.
|
||||
if s.echoEngine != nil {
|
||||
@@ -793,6 +816,9 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
if s.chainRuns != nil {
|
||||
s.chainRuns.Clear()
|
||||
}
|
||||
if s.activeScans != nil {
|
||||
s.activeScans.Clear()
|
||||
}
|
||||
if s.chainStore != nil {
|
||||
s.chainStore.ReplaceAll(cfg.Chains)
|
||||
}
|
||||
@@ -809,6 +835,12 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
}
|
||||
}
|
||||
|
||||
// Active-rule toggles are project data; a nil slice (an older file, or a
|
||||
// project that never changed them) clears the disabled set, so every rule runs.
|
||||
if s.activeScanRules != nil {
|
||||
s.activeScanRules.SetDisabled(cfg.ActiveScanDisabledRules)
|
||||
}
|
||||
|
||||
// Apply team server settings.
|
||||
s.mu.Lock()
|
||||
nickname := cfg.TeamNickname
|
||||
|
||||
@@ -173,6 +173,17 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/chain/runs/{id}/stop", s.handleChainStopRun)
|
||||
mux.HandleFunc("DELETE /api/v1/chain/runs/{id}", s.handleChainDeleteRun)
|
||||
|
||||
// Active scanning. Operator-initiated rules that drive a target and file
|
||||
// findings into Detect; DOM XSS is the first. Scope-respecting (a scan with
|
||||
// scope off is never rejected), proxy-mode only, one run at a time.
|
||||
mux.HandleFunc("GET /api/v1/activescan/rules", s.handleActiveScanRules)
|
||||
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/enabled", s.handleSetActiveScanRuleEnabled)
|
||||
mux.HandleFunc("POST /api/v1/activescan/runs", s.handleActiveScanStart)
|
||||
mux.HandleFunc("GET /api/v1/activescan/runs", s.handleActiveScanListRuns)
|
||||
mux.HandleFunc("GET /api/v1/activescan/runs/{id}", s.handleActiveScanGetRun)
|
||||
mux.HandleFunc("POST /api/v1/activescan/runs/{id}/stop", s.handleActiveScanStopRun)
|
||||
mux.HandleFunc("DELETE /api/v1/activescan/runs/{id}", s.handleActiveScanDeleteRun)
|
||||
|
||||
// Passive detection. Unlike the other rule collections below, detect rules
|
||||
// have a PUT (a rule's ID is referenced by every finding it produced) and a
|
||||
// test endpoint.
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/domxss"
|
||||
"github.com/BishopFox/joro/internal/anomaly"
|
||||
"github.com/BishopFox/joro/internal/apiscan"
|
||||
"github.com/BishopFox/joro/internal/automation"
|
||||
@@ -120,6 +122,13 @@ type APIServer struct {
|
||||
chainStore *chain.Store
|
||||
chainRuns *chainrun.Store
|
||||
|
||||
// Active scanning: operator-initiated rules that drive a target and file
|
||||
// findings into the shared detect store. Both are nil in listener/team mode
|
||||
// (the routes are proxy-mode only); runs are session state cleared on a
|
||||
// project switch, and the registry is the rule set the host offers.
|
||||
activeScans *activescan.Store
|
||||
activeScanRules *activescan.Registry
|
||||
|
||||
// Passive detection. All three are nil in listener and team-server mode; the
|
||||
// detect routes are gated on proxy mode in registerRoutes, so only the shared
|
||||
// config paths need nil guards.
|
||||
@@ -280,6 +289,7 @@ func New(
|
||||
specRuns: apiscan.NewStore(),
|
||||
chainStore: chain.NewStore(),
|
||||
chainRuns: chainrun.NewStore(),
|
||||
activeScans: activescan.NewStore(),
|
||||
pluginManager: pluginManager,
|
||||
|
||||
detectEngine: detectEngine,
|
||||
@@ -317,6 +327,11 @@ func New(
|
||||
s.echoEngine = echo.NewEngine(
|
||||
s.echoStore, store, scope, detectFindings,
|
||||
hub.Broadcast(), s.broadcastDetectSummary)
|
||||
// Active-scan rule set. The registry is built here (not a package global, per
|
||||
// the no-globals rule) so the api layer can import both activescan and its
|
||||
// rules without an import cycle.
|
||||
s.activeScanRules = activescan.NewRegistry()
|
||||
s.activeScanRules.Register(domxss.New())
|
||||
// Both stores are built here rather than in SetAutomation, because both outlive
|
||||
// automation: a webhook references a trigger, and neither needs an agent to be useful.
|
||||
// See initTriggers for why the trigger store moved out of newScriptManager.
|
||||
|
||||
+100
-10
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -106,6 +107,11 @@ type LaunchOptions struct {
|
||||
ProfileDir string
|
||||
URL string
|
||||
WipeOnExit bool // remove ProfileDir once the browser process exits
|
||||
|
||||
// RemoteDebugPort and Headless apply only to LaunchControlled. A zero port
|
||||
// means allocate a free one.
|
||||
RemoteDebugPort int
|
||||
Headless bool
|
||||
}
|
||||
|
||||
// Launch starts the browser detached, routing all traffic through the proxy and
|
||||
@@ -118,18 +124,10 @@ func Launch(opts LaunchOptions) error {
|
||||
if target == "" {
|
||||
target = "about:blank"
|
||||
}
|
||||
args := []string{
|
||||
"--proxy-server=http://" + opts.ProxyAddr,
|
||||
"--proxy-bypass-list=<-loopback>", // route loopback targets through the proxy too
|
||||
"--user-data-dir=" + opts.ProfileDir, // the SPKI flag is only honored when a user-data-dir is set
|
||||
"--ignore-certificate-errors-spki-list=" + opts.SPKIFingerprint,
|
||||
"--test-type", // suppresses the unsupported-command-line-flag infobar; does not alter cert validation
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--new-window",
|
||||
args := append(baseArgs(opts), "--new-window",
|
||||
// The URL must be last: Chrome treats the first non-switch arg as a URL.
|
||||
target,
|
||||
}
|
||||
)
|
||||
|
||||
cmd := exec.Command(opts.BrowserPath, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -151,6 +149,98 @@ func Launch(opts LaunchOptions) error {
|
||||
return cmd.Process.Release()
|
||||
}
|
||||
|
||||
// baseArgs builds the launch flags shared by the interactive and controlled
|
||||
// launches: route everything through the proxy, trust only the pinned SPKI, and
|
||||
// keep a private profile. The URL, if any, must be appended last by the caller —
|
||||
// Chrome treats the first non-switch arg as a URL.
|
||||
func baseArgs(opts LaunchOptions) []string {
|
||||
return []string{
|
||||
"--proxy-server=http://" + opts.ProxyAddr,
|
||||
"--proxy-bypass-list=<-loopback>", // route loopback targets through the proxy too
|
||||
"--user-data-dir=" + opts.ProfileDir, // the SPKI flag is only honored when a user-data-dir is set
|
||||
"--ignore-certificate-errors-spki-list=" + opts.SPKIFingerprint,
|
||||
"--test-type", // suppresses the unsupported-command-line-flag infobar; does not alter cert validation
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
}
|
||||
}
|
||||
|
||||
// Session is a controlled browser the caller drives over CDP and must tear down.
|
||||
// Unlike Launch, the process handle is retained so DebugPort can be dialled and
|
||||
// Kill can stop it.
|
||||
type Session struct {
|
||||
cmd *exec.Cmd
|
||||
ProfileDir string
|
||||
DebugPort int
|
||||
}
|
||||
|
||||
// LaunchControlled starts a headless browser with a loopback DevTools endpoint
|
||||
// and returns a handle to it. The profile is NOT wiped here — the caller owns
|
||||
// the session and wipes via Kill — and the process is not released, so Kill can
|
||||
// stop it when the scan is done or cancelled.
|
||||
//
|
||||
// It is the active-scan path: a dedicated instance that never touches the
|
||||
// operator's interactive browser or its persistent profile. CDP's injection
|
||||
// runs before any page script and is not subject to the page's CSP, which is why
|
||||
// a scan can instrument a target the proxy could never inject into.
|
||||
func LaunchControlled(opts LaunchOptions) (*Session, error) {
|
||||
if err := os.MkdirAll(opts.ProfileDir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port := opts.RemoteDebugPort
|
||||
if port == 0 {
|
||||
p, err := freePort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocate debug port: %w", err)
|
||||
}
|
||||
port = p
|
||||
}
|
||||
|
||||
args := baseArgs(opts)
|
||||
args = append(args,
|
||||
fmt.Sprintf("--remote-debugging-port=%d", port),
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-allow-origins=*", // required by modern Chrome to accept a CDP WebSocket
|
||||
"--disable-gpu",
|
||||
"--disable-background-networking",
|
||||
)
|
||||
if opts.Headless {
|
||||
args = append(args, "--headless=new")
|
||||
}
|
||||
// A controlled session drives navigation over CDP, so it opens on a blank
|
||||
// page rather than a start URL.
|
||||
args = append(args, "about:blank")
|
||||
|
||||
cmd := exec.Command(opts.BrowserPath, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{cmd: cmd, ProfileDir: opts.ProfileDir, DebugPort: port}, nil
|
||||
}
|
||||
|
||||
// Kill stops the browser and, when wipe is set, removes its profile directory.
|
||||
func (s *Session) Kill(wipe bool) {
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
_ = s.cmd.Wait()
|
||||
}
|
||||
if wipe && s.ProfileDir != "" && !profileInUse(s.ProfileDir) {
|
||||
_ = os.RemoveAll(s.ProfileDir)
|
||||
}
|
||||
}
|
||||
|
||||
// freePort asks the OS for an unused loopback TCP port. There is a small window
|
||||
// between closing the listener and the browser binding the port, acceptable for
|
||||
// a single local launch.
|
||||
func freePort() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
// profileInUse reports whether a Chromium instance still holds the profile's
|
||||
// singleton lock (SingletonLock on Unix, lockfile on Windows).
|
||||
func profileInUse(profileDir string) bool {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package cdp is a minimal Chrome DevTools Protocol client: JSON-RPC 2.0 over a
|
||||
// single WebSocket to a locally launched headless browser.
|
||||
//
|
||||
// It is hand-rolled on gorilla/websocket rather than vendoring chromedp or rod,
|
||||
// the same choice internal/mcp (a hand-rolled JSON-RPC server) and the C2 clients
|
||||
// make — the surface needed here is a handful of methods and two events, and a
|
||||
// browser-automation dependency tree is a large supply-chain cost for that.
|
||||
//
|
||||
// # Threat model
|
||||
//
|
||||
// The client only ever connects to 127.0.0.1 on a port Joro itself just launched
|
||||
// a browser with. A DevTools endpoint is unauthenticated and grants full control
|
||||
// of the browser, so it must never be exposed off the loopback interface; the
|
||||
// launch flags in internal/browser bind it to 127.0.0.1 and the dialer here
|
||||
// refuses anything else. This mirrors the MCP listener's loopback-only posture.
|
||||
//
|
||||
// # Flat session
|
||||
//
|
||||
// The client attaches directly to one page target's debugger URL, so every
|
||||
// command and event is for that page and no sessionId plumbing is needed. Driving
|
||||
// several tabs would need Target.setAutoAttach and per-target sessions; a scan
|
||||
// drives one page in sequence, so it does not.
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Client is a connection to one page target.
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]chan rpcResponse
|
||||
handlers map[string][]func(json.RawMessage)
|
||||
closed bool
|
||||
}
|
||||
|
||||
// rpcRequest is one outbound JSON-RPC command.
|
||||
type rpcRequest struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// rpcResponse is a reply or an event. A message with an id is a reply to the
|
||||
// command that carried that id; one without is an event named by Method.
|
||||
type rpcResponse struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string { return fmt.Sprintf("cdp error %d: %s", e.Code, e.Message) }
|
||||
|
||||
// targetInfo is one entry of the /json listing.
|
||||
type targetInfo struct {
|
||||
Type string `json:"type"`
|
||||
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
|
||||
// Connect discovers the page target on a loopback DevTools endpoint and opens a
|
||||
// WebSocket to it. debugPort is the --remote-debugging-port the browser was
|
||||
// launched with.
|
||||
func Connect(ctx context.Context, debugPort int, timeout time.Duration) (*Client, error) {
|
||||
wsURL, err := discoverPageWS(ctx, debugPort, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
|
||||
// Modern Chrome rejects a CDP WebSocket whose Origin is not allowlisted; the
|
||||
// browser is launched with --remote-allow-origins=* (loopback only), and the
|
||||
// matching Origin header is sent here.
|
||||
hdr := http.Header{"Origin": {fmt.Sprintf("http://127.0.0.1:%d", debugPort)}}
|
||||
conn, _, err := dialer.DialContext(ctx, wsURL, hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp dial: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
pending: make(map[int]chan rpcResponse),
|
||||
handlers: make(map[string][]func(json.RawMessage)),
|
||||
}
|
||||
go c.readLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// discoverPageWS polls http://127.0.0.1:<port>/json until a page target appears,
|
||||
// returning its WebSocket debugger URL. The browser writes the port and the
|
||||
// listing only once it is ready, so a short poll is simpler than racing it.
|
||||
func discoverPageWS(ctx context.Context, port int, timeout time.Duration) (string, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
endpoint := fmt.Sprintf("http://127.0.0.1:%d/json", port)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
ws, err := fetchPageWS(ctx, endpoint)
|
||||
if err == nil && ws != "" {
|
||||
return ws, nil
|
||||
}
|
||||
lastErr = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("cdp discovery timed out: %w", lastErr)
|
||||
}
|
||||
return "", fmt.Errorf("cdp discovery timed out: no page target on port %d", port)
|
||||
}
|
||||
|
||||
func fetchPageWS(ctx context.Context, endpoint string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var targets []targetInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
|
||||
if _, err := url.Parse(t.WebSocketDebuggerURL); err == nil {
|
||||
return t.WebSocketDebuggerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// readLoop dispatches replies to their waiting caller and events to handlers.
|
||||
func (c *Client) readLoop() {
|
||||
for {
|
||||
var msg rpcResponse
|
||||
if err := c.conn.ReadJSON(&msg); err != nil {
|
||||
c.fail()
|
||||
return
|
||||
}
|
||||
if msg.ID != 0 {
|
||||
c.mu.Lock()
|
||||
ch := c.pending[msg.ID]
|
||||
delete(c.pending, msg.ID)
|
||||
c.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- msg
|
||||
}
|
||||
continue
|
||||
}
|
||||
if msg.Method != "" {
|
||||
c.mu.Lock()
|
||||
hs := append([]func(json.RawMessage){}, c.handlers[msg.Method]...)
|
||||
c.mu.Unlock()
|
||||
for _, h := range hs {
|
||||
h(msg.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fail wakes every pending caller when the socket dies, so a Send cannot hang
|
||||
// past the connection.
|
||||
func (c *Client) fail() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
for id, ch := range c.pending {
|
||||
close(ch)
|
||||
delete(c.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Send issues a command and waits for its reply (or the context's deadline).
|
||||
func (c *Client) Send(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("cdp: connection closed")
|
||||
}
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
ch := make(chan rpcResponse, 1)
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.conn.WriteJSON(rpcRequest{ID: id, Method: method, Params: params}); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("cdp write %s: %w", method, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case resp, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cdp: connection closed during %s", method)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, resp.Error
|
||||
}
|
||||
return resp.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// On registers a handler for a CDP event, e.g. "Runtime.bindingCalled". Handlers
|
||||
// run on the read loop, so they must not block.
|
||||
func (c *Client) On(method string, fn func(params json.RawMessage)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.handlers[method] = append(c.handlers[method], fn)
|
||||
}
|
||||
|
||||
// Close tears down the socket.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
already := c.closed
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
if !already {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
}
|
||||
@@ -124,13 +124,20 @@ const (
|
||||
// reflection that cannot escape its context is mapped there and never filed
|
||||
// here.
|
||||
CategoryReflection Category = "reflection"
|
||||
// CategoryDOMXSS holds runtime-confirmed DOM-based cross-site scripting: a
|
||||
// canary placed in a controllable DOM source (the URL fragment, a query
|
||||
// value, window.name) that the page's own JavaScript carried into a dangerous
|
||||
// sink. These come from internal/activescan driving a headless browser over
|
||||
// CDP, not the passive rule engine — the payload never reaches the server, so
|
||||
// no captured response could reveal it.
|
||||
CategoryDOMXSS Category = "dom-xss"
|
||||
)
|
||||
|
||||
// Categories lists every category. See Severities.
|
||||
var Categories = []Category{
|
||||
CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection,
|
||||
CategoryReflection, CategoryDOMXSS,
|
||||
}
|
||||
|
||||
// Valid reports whether c is a known category.
|
||||
@@ -138,7 +145,7 @@ func (c Category) Valid() bool {
|
||||
switch c {
|
||||
case CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection:
|
||||
CategoryReflection, CategoryDOMXSS:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -149,7 +156,7 @@ func AllCategories() []Category {
|
||||
return []Category{
|
||||
CategorySecrets, CategoryCredentials, CategoryPII, CategoryAccess,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection,
|
||||
CategoryReflection, CategoryDOMXSS,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import Toasts from './components/Toasts'
|
||||
import ActiveScanIndicator from './components/ActiveScanIndicator'
|
||||
import UpdateBanner from './components/UpdateBanner'
|
||||
import PluginBanner from './components/PluginBanner'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
@@ -216,6 +217,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<Toasts />
|
||||
<ActiveScanIndicator />
|
||||
<UpdateBanner />
|
||||
<PluginBanner failed={failedPlugins} />
|
||||
{/* Top nav */}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Scan } from 'lucide-react'
|
||||
import { useActiveScanStore } from '../stores/activeScanStore'
|
||||
|
||||
// A small fixed pill shown while an active scan runs. It derives the running run
|
||||
// from the shared run list. Findings surface in the Detect tab; this only reports
|
||||
// progress.
|
||||
export default function ActiveScanIndicator() {
|
||||
const run = useActiveScanStore((s) => s.runs.find((r) => r.status === 'running'))
|
||||
if (!run) return null
|
||||
const pct = run.total > 0 ? Math.min(100, Math.round((run.completed / run.total) * 100)) : 0
|
||||
return (
|
||||
<div className="fixed bottom-3 right-3 z-50 w-64 rounded-md border border-border bg-surface-card shadow-lg">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Scan size={14} strokeWidth={1.8} className="text-accent-tertiary animate-pulse" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[11px] font-semibold text-content-primary">Scanning {run.host}</div>
|
||||
<div className="text-[10px] text-content-muted">
|
||||
{run.completed} of {run.total} · {run.findings} finding{run.findings === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-b-md bg-surface-input">
|
||||
<div className="h-full bg-accent-tertiary transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1097,6 +1097,20 @@ async function upload<T>(path: string, form: FormData): Promise<T> {
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export interface ActiveScanRun {
|
||||
id: string
|
||||
host: string
|
||||
origin: string
|
||||
scope: string
|
||||
rules: string[]
|
||||
status: 'running' | 'complete' | 'stopped'
|
||||
total: number
|
||||
completed: number
|
||||
errors: number
|
||||
findings: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Sitemap
|
||||
getSitemap: (params: Record<string, string | number> = {}) => {
|
||||
@@ -1765,6 +1779,35 @@ export const api = {
|
||||
getDetectScan: () => req<ScanState>('GET', '/detect/scan'),
|
||||
cancelDetectScan: () => req<{ status: string }>('POST', '/detect/scan/cancel'),
|
||||
|
||||
// Active scanning — operator-initiated rules (DOM XSS is the first) that drive a
|
||||
// target and file findings into Detect. Scope "host" scans every captured GET
|
||||
// endpoint for origin; "request" scans one captured request or a raw url.
|
||||
activeScanRules: () =>
|
||||
req<{ rules: { id: string; name: string; category: string; enabled: boolean }[] }>(
|
||||
'GET',
|
||||
'/activescan/rules',
|
||||
),
|
||||
setActiveScanRuleEnabled: (id: string, enabled: boolean) =>
|
||||
req<{ enabled: boolean }>('PUT', `/activescan/rules/${id}/enabled`, { enabled }),
|
||||
startActiveScan: (body: {
|
||||
scope: 'host' | 'request'
|
||||
origin?: string
|
||||
url?: string
|
||||
requestId?: string
|
||||
rules?: string[]
|
||||
}) =>
|
||||
req<{ runId: string; total: number; rules: string[]; urls: number; skipped: number }>(
|
||||
'POST',
|
||||
'/activescan/runs',
|
||||
body,
|
||||
),
|
||||
listActiveScanRuns: () => req<{ runs: ActiveScanRun[] }>('GET', '/activescan/runs'),
|
||||
getActiveScanRun: (id: string) =>
|
||||
req<ActiveScanRun & { errorMessages: string[] }>('GET', `/activescan/runs/${id}`),
|
||||
stopActiveScan: (id: string) =>
|
||||
req<{ stopped: boolean }>('POST', `/activescan/runs/${id}/stop`),
|
||||
deleteActiveScan: (id: string) => req<{ deleted: boolean }>('DELETE', `/activescan/runs/${id}`),
|
||||
|
||||
// Highlights
|
||||
getHighlights: () => req<{ highlights: Record<string, string> }>('GET', '/highlights'),
|
||||
setHighlight: (id: string, color: string) => req<unknown>('PUT', `/highlights/${id}`, { color }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSettingsStore, type Settings } from '../stores/settingsStore'
|
||||
import { useSJStore } from '../stores/sjStore'
|
||||
import { useEchoStore } from '../stores/echoStore'
|
||||
import { useChainStore } from '../stores/chainStore'
|
||||
import { useActiveScanStore } from '../stores/activeScanStore'
|
||||
|
||||
// applyProjectResp propagates a project load/switch response to global live
|
||||
// state: it invalidates cached request history, refreshes settings into the
|
||||
@@ -29,6 +30,7 @@ export function applyProjectResp(_resp: unknown): void {
|
||||
// previous engagement, and its verdicts were computed against a baseline
|
||||
// measured there.
|
||||
useChainStore.getState().clearAll()
|
||||
useActiveScanStore.getState().clear()
|
||||
api
|
||||
.getSettings()
|
||||
.then((s) => useSettingsStore.getState().setSettings(s as Settings))
|
||||
|
||||
@@ -16,6 +16,7 @@ export const CATEGORY_OPTIONS = [
|
||||
{ key: 'headers', label: 'Headers', title: 'Security header and CORS issues' },
|
||||
{ key: 'cookies', label: 'Cookies', title: 'Cookie flag issues' },
|
||||
{ key: 'anomaly', label: 'Anomaly', title: 'Endpoints unlike their siblings on the same host' },
|
||||
{ key: 'dom-xss', label: 'DOM XSS', title: 'Runtime-confirmed DOM-based cross-site scripting' },
|
||||
]
|
||||
|
||||
export const TARGET_OPTIONS = [
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api, ApiError } from './api'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
// initiateScan starts an active scan from a context menu and surfaces the result
|
||||
// as a toast. Progress and findings arrive over the activescan.* / detect.* WS
|
||||
// events, so there is nothing to navigate to — findings land in the Detect tab.
|
||||
export async function initiateScan(body: {
|
||||
scope: 'host' | 'request'
|
||||
origin?: string
|
||||
url?: string
|
||||
requestId?: string
|
||||
}): Promise<void> {
|
||||
const addToast = useToastStore.getState().addToast
|
||||
try {
|
||||
const res = await api.startActiveScan(body)
|
||||
const skipped = res.skipped > 0 ? ` (${res.skipped} out of scope)` : ''
|
||||
addToast(`Active scan started — ${res.urls} URL${res.urls === 1 ? '' : 's'}${skipped}`, 'info')
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String((e as Error).message ?? e)
|
||||
addToast(msg, 'error')
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useActiveScanStore } from '../stores/activeScanStore'
|
||||
import { useAutomationStore } from '../stores/automationStore'
|
||||
import { useCallbackStore, type CallbackInteraction } from '../stores/callbackStore'
|
||||
import { useDetectStore, type Finding, type DetectSummary } from '../stores/detectStore'
|
||||
@@ -415,6 +416,32 @@ function handleMessage(msg: WSMessage) {
|
||||
useFuzzStore.getState().setCampaignStatus(d.campaignId, d.status === 'stopped' ? 'stopped' : 'completed')
|
||||
break
|
||||
}
|
||||
case 'activescan.started': {
|
||||
// The start toast fires from the initiating HTTP response (scanMenu); this
|
||||
// builds the run row for the Scans tab + the floating pill.
|
||||
const d = msg.data as {
|
||||
runId: string; host: string; origin: string; scope: string; rules: string[]; total: number
|
||||
}
|
||||
useActiveScanStore.getState().applyStarted(d)
|
||||
break
|
||||
}
|
||||
case 'activescan.progress': {
|
||||
const d = msg.data as { runId: string; scanned: number; total: number; findings: number }
|
||||
useActiveScanStore.getState().applyProgress(d)
|
||||
break
|
||||
}
|
||||
case 'activescan.complete': {
|
||||
const d = msg.data as {
|
||||
runId: string; status: string; completed: number; errors: number; findings: number
|
||||
}
|
||||
useActiveScanStore.getState().applyComplete(d)
|
||||
const verb = d.status === 'stopped' ? 'stopped' : 'complete'
|
||||
useToastStore.getState().addToast(
|
||||
`Active scan ${verb} — ${d.findings} finding${d.findings === 1 ? '' : 's'}`,
|
||||
'info',
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'detect.finding': {
|
||||
const d = msg.data as { finding: Finding; isNew: boolean }
|
||||
findingBuffer.push(d.finding)
|
||||
|
||||
+174
-3
@@ -15,6 +15,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Radio,
|
||||
Scan,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
WrapText,
|
||||
@@ -29,7 +30,8 @@ import {
|
||||
} from '@codemirror/view'
|
||||
import type { Range } from '@codemirror/state'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { api } from '../lib/api'
|
||||
import { api, type ActiveScanRun } from '../lib/api'
|
||||
import { useActiveScanStore } from '../stores/activeScanStore'
|
||||
import { ResponseRender, usePrettyJson } from '../components/ResponseRender'
|
||||
import LensOutput from '../components/LensOutput'
|
||||
import TabButton from '../components/TabButton'
|
||||
@@ -198,7 +200,8 @@ function evidenceHighlightPlugin(
|
||||
}
|
||||
|
||||
export default function Detect() {
|
||||
const [tab, setTab] = useState<'findings' | 'rules'>('findings')
|
||||
const [tab, setTab] = useState<'findings' | 'rules' | 'scans'>('findings')
|
||||
const scanRunning = useActiveScanStore((s) => s.runs.some((r) => r.status === 'running'))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
@@ -207,6 +210,7 @@ export default function Detect() {
|
||||
[
|
||||
['findings', 'Findings'],
|
||||
['rules', 'Rules'],
|
||||
['scans', 'Scans'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
@@ -219,10 +223,13 @@ export default function Detect() {
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{key === 'scans' && scanRunning && (
|
||||
<span className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-accent-tertiary animate-pulse align-middle" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'findings' ? <FindingsView /> : <RulesView />}
|
||||
{tab === 'findings' ? <FindingsView /> : tab === 'rules' ? <RulesView /> : <ScansView />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1201,6 +1208,26 @@ function RulesView() {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
// Active-scan rules come from a separate registry endpoint and are few and
|
||||
// static apart from their enabled toggle, so they live in local state rather
|
||||
// than the detect store. null means not-yet-loaded (or unavailable in this mode).
|
||||
const [activeRules, setActiveRules] = useState<
|
||||
{ id: string; name: string; category: string; enabled: boolean }[] | null
|
||||
>(null)
|
||||
useEffect(() => {
|
||||
api
|
||||
.activeScanRules()
|
||||
.then((d) => setActiveRules(d.rules))
|
||||
.catch(() => setActiveRules(null))
|
||||
}, [])
|
||||
const toggleActiveRule = (id: string, enabled: boolean) => {
|
||||
setActiveRules((prev) => prev?.map((r) => (r.id === id ? { ...r, enabled } : r)) ?? prev)
|
||||
api.setActiveScanRuleEnabled(id, enabled).catch((err) => {
|
||||
addToast((err as Error).message, 'error')
|
||||
setActiveRules((prev) => prev?.map((r) => (r.id === id ? { ...r, enabled: !enabled } : r)) ?? prev)
|
||||
})
|
||||
}
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
try {
|
||||
const d = await api.listDetectRules()
|
||||
@@ -1467,6 +1494,40 @@ function RulesView() {
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{activeRules && activeRules.length > 0 && (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 bg-surface-card text-content-muted">
|
||||
<Scan size={12} strokeWidth={1.8} className="text-accent-tertiary" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide">Active rules</span>
|
||||
<span className="text-[10px] text-content-muted">· run on demand via "Initiate scan"</span>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{activeRules.map((r) => (
|
||||
<tr key={r.id} className={`border-b border-border-subtle ${r.enabled ? '' : 'opacity-60'}`}>
|
||||
<td className="px-2 py-1 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={r.enabled}
|
||||
onChange={(e) => toggleActiveRule(r.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<div className="text-content-secondary">{r.name}</div>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-sm bg-surface-input text-content-muted">
|
||||
{r.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 text-[10px] text-content-muted">active · operator-initiated</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-surface-card text-content-muted uppercase">
|
||||
<tr>
|
||||
@@ -1609,3 +1670,113 @@ function RulesView() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ScansView lists active-scan runs with live progress. Runs are session state in
|
||||
// useActiveScanStore, fed by the activescan.* WS events; this view seeds the list
|
||||
// from the server on mount and on reconnect, then renders live.
|
||||
function ScansView() {
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const runs = useActiveScanStore((s) => s.runs)
|
||||
const { setRuns, removeRun } = useActiveScanStore()
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api
|
||||
.listActiveScanRuns()
|
||||
.then((d) => setRuns(d.runs))
|
||||
.catch(() => {})
|
||||
}, [setRuns])
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
window.addEventListener('joro:ws-reconnected', reload)
|
||||
return () => window.removeEventListener('joro:ws-reconnected', reload)
|
||||
}, [reload])
|
||||
|
||||
const stop = (id: string) => {
|
||||
api.stopActiveScan(id).catch((err) => addToast((err as Error).message, 'error'))
|
||||
}
|
||||
const remove = (id: string) => {
|
||||
api
|
||||
.deleteActiveScan(id)
|
||||
.then(() => removeRun(id))
|
||||
.catch((err) => addToast((err as Error).message, 'error'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border bg-surface-card shrink-0">
|
||||
<Scan size={13} strokeWidth={1.8} className="text-accent-tertiary" />
|
||||
<span className="text-xs font-semibold text-content-secondary">Active scans</span>
|
||||
<span className="text-[10px] text-content-muted">
|
||||
{runs.length} run{runs.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{runs.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-center text-xs text-content-muted px-6">
|
||||
No scans yet. Right-click a host in the Site Map or a request in History and choose
|
||||
“Initiate scan”.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto min-h-0 divide-y divide-border-subtle">
|
||||
{runs.map((run) => (
|
||||
<ScanRow key={run.id} run={run} onStop={stop} onDelete={remove} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScanRow({
|
||||
run,
|
||||
onStop,
|
||||
onDelete,
|
||||
}: {
|
||||
run: ActiveScanRun
|
||||
onStop: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const pct = run.total > 0 ? Math.min(100, Math.round((run.completed / run.total) * 100)) : 0
|
||||
const statusClass =
|
||||
run.status === 'running'
|
||||
? 'text-accent-tertiary'
|
||||
: run.status === 'stopped'
|
||||
? 'text-semantic-warning'
|
||||
: 'text-content-muted'
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-content-primary truncate">{run.origin || run.host}</span>
|
||||
<span className={`text-[10px] uppercase font-semibold ${statusClass}`}>{run.status}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-content-muted truncate">
|
||||
{run.scope} · {run.rules.join(', ') || 'all rules'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-content-muted shrink-0">
|
||||
<span>
|
||||
{run.completed}/{run.total}
|
||||
</span>
|
||||
<span className="text-content-secondary">
|
||||
{run.findings} finding{run.findings === 1 ? '' : 's'}
|
||||
</span>
|
||||
{run.errors > 0 && <span className="text-semantic-error">{run.errors} err</span>}
|
||||
{run.status === 'running' ? (
|
||||
<button onClick={() => onStop(run.id)} className="text-semantic-error hover:underline">
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => onDelete(run.id)} className="text-content-muted hover:text-content-primary">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 bg-surface-input rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent-tertiary transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 { initiateScan } from '../lib/scanMenu'
|
||||
import { copyText } from '../lib/clipboard'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
@@ -1149,6 +1150,7 @@ function HTTPHistory() {
|
||||
...(rowSeq !== undefined
|
||||
? [{ label: 'Add to Chain', onClick: () => sendToChain(rowSeq) }]
|
||||
: []),
|
||||
{ label: 'Initiate scan', onClick: () => initiateScan({ scope: 'request', requestId: rowMenu.itemId }) },
|
||||
{ label: 'Stage for Dead Drop', onClick: () => stageForDeadDrop(rowMenu.itemId) },
|
||||
]}
|
||||
/>
|
||||
@@ -1167,6 +1169,7 @@ function HTTPHistory() {
|
||||
{ label: 'Fuzz', onClick: sendToFuzz },
|
||||
{ label: 'SJ', onClick: sendToSJ },
|
||||
{ label: 'Add to Chain', onClick: () => sendToChain(selectedDetail.seq) },
|
||||
{ label: 'Initiate scan', onClick: () => initiateScan({ scope: 'request', requestId: selectedDetail.id }) },
|
||||
{ label: 'Stage for Dead Drop', onClick: () => stageDetailForDeadDrop(selectedDetail) },
|
||||
...(teamMode ? [{ label: 'Flag to team', icon: <Flag size={13} />, onClick: flagToTeam }] : []),
|
||||
{ label: 'Copy URL', onClick: copyUrl },
|
||||
|
||||
@@ -16,6 +16,7 @@ import { changeRequestType, changeContentType, getMethod, getContentType, rawToC
|
||||
import ContextMenu from '../components/ContextMenu'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { getSelectionMenuItems } from '../lib/selectionMenu'
|
||||
import { initiateScan } from '../lib/scanMenu'
|
||||
import { copyText } from '../lib/clipboard'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
@@ -473,6 +474,7 @@ export default function ManipulateHTTP() {
|
||||
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} onClose={handleCloseCtxMenu} items={[
|
||||
...getSelectionMenuItems(navigate),
|
||||
{ label: 'Fuzz', onClick: sendToFuzz },
|
||||
{ label: 'Initiate scan', onClick: () => initiateScan({ scope: 'request', url: getRequestUrl() }) },
|
||||
{
|
||||
label: method === 'GET' ? 'Change to POST' : 'Change to GET',
|
||||
onClick: () => updateTab(tab.id, { rawReq: changeRequestType(tab.rawReq) }),
|
||||
|
||||
+24
-1
@@ -14,6 +14,7 @@ import TabButton from '../components/TabButton'
|
||||
import { useLenses } from '../lib/lenses'
|
||||
import { useResizable } from '../lib/useResizable'
|
||||
import ContextMenu from '../components/ContextMenu'
|
||||
import { initiateScan } from '../lib/scanMenu'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { Filter, ChevronRight, X, WrapText, SlidersHorizontal } from 'lucide-react'
|
||||
@@ -76,6 +77,7 @@ export default function Map() {
|
||||
: 'raw'
|
||||
const [prettyJson, setPrettyJson] = usePrettyJson()
|
||||
const [detailMenu, setDetailMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
const [hostMenu, setHostMenu] = useState<{ x: number; y: number; origin: string } | null>(null)
|
||||
|
||||
// Inline delete on tree rows.
|
||||
type DeleteTarget = { kind: 'host' | 'endpoint'; origin: string; path?: string }
|
||||
@@ -334,7 +336,13 @@ export default function Map() {
|
||||
return (
|
||||
<div key={host.origin}>
|
||||
{/* Host row */}
|
||||
<div className="group flex items-center rounded-sm hover:bg-surface-hover transition-colors">
|
||||
<div
|
||||
className="group flex items-center rounded-sm hover:bg-surface-hover transition-colors"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setHostMenu({ x: e.clientX, y: e.clientY, origin: host.origin })
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleHost(host.origin)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left px-2 py-1.5"
|
||||
@@ -636,6 +644,20 @@ export default function Map() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{hostMenu && (
|
||||
<ContextMenu
|
||||
x={hostMenu.x}
|
||||
y={hostMenu.y}
|
||||
onClose={() => setHostMenu(null)}
|
||||
items={[
|
||||
{
|
||||
label: 'Initiate scan',
|
||||
onClick: () => initiateScan({ scope: 'host', origin: hostMenu.origin }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailMenu && selectedDetail && (
|
||||
<ContextMenu
|
||||
x={detailMenu.x}
|
||||
@@ -645,6 +667,7 @@ export default function Map() {
|
||||
...getSelectionMenuItems(navigate),
|
||||
{ label: 'Manipulate', onClick: sendToManipulate },
|
||||
{ label: 'Fuzz', onClick: sendToFuzz },
|
||||
{ label: 'Initiate scan', onClick: () => initiateScan({ scope: 'request', requestId: selectedDetail.id }) },
|
||||
{ label: 'Copy URL', onClick: copyUrl },
|
||||
{ label: 'Copy as curl', onClick: copyCurl },
|
||||
{ label: 'Copy Raw Request', onClick: () => copyRaw('request') },
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ActiveScanRun } from '../lib/api'
|
||||
|
||||
// Active-scan run list, fed by the activescan.* WebSocket events plus an initial
|
||||
// fetch in the Scans sub-tab. It is the single source of truth for both the Scans
|
||||
// tab and the floating ActiveScanIndicator pill (which derives the running run).
|
||||
// Findings themselves arrive through the shared detect.finding / detect.summary
|
||||
// handlers and land in the Detect tab.
|
||||
export interface ActiveScanState {
|
||||
runs: ActiveScanRun[] // newest first
|
||||
setRuns: (runs: ActiveScanRun[]) => void
|
||||
applyStarted: (p: {
|
||||
runId: string
|
||||
host: string
|
||||
origin: string
|
||||
scope: string
|
||||
rules: string[]
|
||||
total: number
|
||||
}) => void
|
||||
applyProgress: (p: { runId: string; scanned: number; total: number; findings: number }) => void
|
||||
applyComplete: (p: {
|
||||
runId: string
|
||||
status: string
|
||||
completed?: number
|
||||
errors?: number
|
||||
findings: number
|
||||
}) => void
|
||||
removeRun: (id: string) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export const useActiveScanStore = create<ActiveScanState>((set) => ({
|
||||
runs: [],
|
||||
setRuns: (runs) => set({ runs }),
|
||||
applyStarted: ({ runId, host, origin, scope, rules, total }) =>
|
||||
set((s) => {
|
||||
const row: ActiveScanRun = {
|
||||
id: runId,
|
||||
host,
|
||||
origin,
|
||||
scope,
|
||||
rules,
|
||||
status: 'running',
|
||||
total,
|
||||
completed: 0,
|
||||
errors: 0,
|
||||
findings: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
const rest = s.runs.filter((r) => r.id !== runId)
|
||||
return { runs: [row, ...rest] }
|
||||
}),
|
||||
applyProgress: ({ runId, scanned, total, findings }) =>
|
||||
set((s) => ({
|
||||
runs: s.runs.map((r) =>
|
||||
r.id === runId ? { ...r, completed: scanned, total, findings } : r,
|
||||
),
|
||||
})),
|
||||
applyComplete: ({ runId, status, completed, errors, findings }) =>
|
||||
set((s) => ({
|
||||
runs: s.runs.map((r) =>
|
||||
r.id === runId
|
||||
? {
|
||||
...r,
|
||||
status: (status === 'stopped' ? 'stopped' : 'complete') as ActiveScanRun['status'],
|
||||
completed: completed ?? r.completed,
|
||||
errors: errors ?? r.errors,
|
||||
findings,
|
||||
}
|
||||
: r,
|
||||
),
|
||||
})),
|
||||
removeRun: (id) => set((s) => ({ runs: s.runs.filter((r) => r.id !== id) })),
|
||||
clear: () => set({ runs: [] }),
|
||||
}))
|
||||
Reference in new issue
Block a user