Add active scanning feature with DOM XSS detection

This commit is contained in:
Tony West committed 2026-10-06 14:18:53 -04:00
1 parent aeb688148b
commit 6e3c834df4
24 files changed
+2143 -25

No files matched your search

+251
View File
@@ -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
}
+279
View File
@@ -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")
}
+141
View File
@@ -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) {}
})();
+233
View File
@@ -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"
}
+98
View File
@@ -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()
}
}
+310
View File
@@ -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})
}
+40 -8
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+253
View File
@@ -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()
}
}
+10 -3
View File
@@ -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,
}
}
+2
View File
@@ -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>
)
}
+43
View File
@@ -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 }),
+2
View File
@@ -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))
+1
View File
@@ -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 = [
+22
View File
@@ -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')
}
}
+27
View File
@@ -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
View File
@@ -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
&ldquo;Initiate scan&rdquo;.
</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>
)
}
+3
View File
@@ -23,6 +23,7 @@ import ContextMenu from '../components/ContextMenu'
import ConfirmModal from '../components/ConfirmModal'
import { Tooltip } from '../components/Tooltip'
import { getSelectionMenuItems } from '../lib/selectionMenu'
import { 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 },
+2
View File
@@ -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
View File
@@ -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') },
+75
View File
@@ -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: [] }),
}))