mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Add built-in wordlists and payload generators to the fuzzer
This commit is contained in:
1 parent
564f7027a7
commit
aeb688148b
9 files changed
+1206
-166
No files matched your search
+109
-17
@@ -22,6 +22,8 @@ type fuzzerStartRequest struct {
|
||||
Host string `json:"host"`
|
||||
Wordlist []string `json:"wordlist"` // Single-position wordlist
|
||||
Wordlists map[string][]string `json:"wordlists,omitempty"` // Multi-position: position → wordlist
|
||||
Source *fuzzer.PayloadSource `json:"source,omitempty"` // Single-position / spray: a built-in list or generator, resolved server-side
|
||||
Sources map[string]fuzzer.PayloadSource `json:"sources,omitempty"` // Split/yolo: a source per position; absent ones fall back to Wordlists
|
||||
AttackMode string `json:"attackMode,omitempty"` // spray, split, yolo
|
||||
Threads int `json:"threads"`
|
||||
RateLimit float64 `json:"rateLimit"`
|
||||
@@ -93,12 +95,22 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(positions) <= 1 {
|
||||
// Single-position mode
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
// Single-position mode. A source (built-in list or generator) resolves
|
||||
// server-side; otherwise the inline wordlist is used as-is.
|
||||
if req.Source != nil {
|
||||
wl, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
cfg.Wordlist = wl
|
||||
} else {
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
}
|
||||
cfg.Wordlist = req.Wordlist
|
||||
}
|
||||
cfg.Wordlist = req.Wordlist
|
||||
} else {
|
||||
// Multi-position mode
|
||||
mode := fuzzer.AttackMode(req.AttackMode)
|
||||
@@ -109,30 +121,46 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
|
||||
cfg.AttackMode = mode
|
||||
|
||||
if mode == fuzzer.AttackSpray {
|
||||
// Spray: single wordlist used for all positions
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
// Spray: single wordlist (or source) used for all positions
|
||||
var wl []string
|
||||
if req.Source != nil {
|
||||
resolved, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
wl = resolved
|
||||
} else {
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
}
|
||||
wl = req.Wordlist
|
||||
}
|
||||
cfg.Wordlists = make(map[string][]string, len(positions))
|
||||
for _, pos := range positions {
|
||||
cfg.Wordlists[pos] = req.Wordlist
|
||||
cfg.Wordlists[pos] = wl
|
||||
}
|
||||
} else {
|
||||
// Split/Yolo: require a wordlist for each position
|
||||
if len(req.Wordlists) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlists map is required for split/yolo modes")
|
||||
return
|
||||
}
|
||||
// Split/Yolo: each position takes a resolved source or an inline wordlist
|
||||
cfg.Wordlists = make(map[string][]string, len(positions))
|
||||
for _, pos := range positions {
|
||||
if src, ok := req.Sources[pos]; ok {
|
||||
resolved, err := src.Resolve(fuzzer.MaxGeneratedPayloads)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("position %s: %v", pos, err))
|
||||
return
|
||||
}
|
||||
cfg.Wordlists[pos] = resolved
|
||||
continue
|
||||
}
|
||||
wl, ok := req.Wordlists[pos]
|
||||
if !ok || len(wl) == 0 {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing or empty wordlist for position %s", pos))
|
||||
return
|
||||
}
|
||||
_ = wl
|
||||
cfg.Wordlists[pos] = wl
|
||||
}
|
||||
cfg.Wordlists = req.Wordlists
|
||||
|
||||
// Safety limit for yolo mode
|
||||
if mode == fuzzer.AttackYolo {
|
||||
@@ -335,3 +363,67 @@ func (s *APIServer) handleFuzzerUploadWordlist(w http.ResponseWriter, r *http.Re
|
||||
"count": len(lines),
|
||||
})
|
||||
}
|
||||
|
||||
// handleFuzzerListWordlists returns the catalog of built-in lists and generators.
|
||||
// Built-in list contents are never sent — only a count — so the picker shows size
|
||||
// without shipping the payloads twice.
|
||||
func (s *APIServer) handleFuzzerListWordlists(w http.ResponseWriter, r *http.Request) {
|
||||
type builtinDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
lists := fuzzer.BuiltinLists()
|
||||
builtins := make([]builtinDTO, 0, len(lists))
|
||||
for _, l := range lists {
|
||||
builtins = append(builtins, builtinDTO{
|
||||
ID: l.ID, Label: l.Label, Description: l.Description,
|
||||
Category: l.Category, Count: len(l.Values),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"builtins": builtins,
|
||||
"generators": fuzzer.Generators(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleFuzzerPreviewSource resolves a payload source and returns its size plus a
|
||||
// small sample, so the editor can show "~N payloads — a, b, c, ..." live. It
|
||||
// reuses the same resolver and cap as a real campaign start.
|
||||
func (s *APIServer) handleFuzzerPreviewSource(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireLocalOrigin(w, r) {
|
||||
return
|
||||
}
|
||||
var src fuzzer.PayloadSource
|
||||
if err := decodeJSON(r, &src); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
count, err := src.Count()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"count": count,
|
||||
"sample": []string{},
|
||||
"exceedsLimit": count > fuzzer.MaxGeneratedPayloads, // cannot be run
|
||||
}
|
||||
// Only materialize for the sample when the list is small enough that doing so
|
||||
// on a debounced keystroke is cheap; the count alone covers the rest.
|
||||
const previewSampleCap = 100_000
|
||||
if count > 0 && count <= previewSampleCap {
|
||||
full, err := src.Resolve(previewSampleCap)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(full) > 20 {
|
||||
full = full[:20]
|
||||
}
|
||||
resp["sample"] = full
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -112,6 +112,8 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}/results/{index}", s.handleFuzzerGetResult)
|
||||
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
|
||||
mux.HandleFunc("GET /api/v1/fuzzer/wordlists", s.handleFuzzerListWordlists)
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/wordlist/preview", s.handleFuzzerPreviewSource)
|
||||
|
||||
// API description documents (the SJ tab). Loads a Swagger/OpenAPI document,
|
||||
// renders each operation to raw bytes, and drives scans, auth-profile
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package fuzzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A PayloadSource is a compact, server-resolved description of a payload list:
|
||||
// a named built-in list or a parametric generator. It exists so the UI can send
|
||||
// a tiny spec (a numeric range, a list id) instead of materializing and shipping
|
||||
// millions of lines inline. Manual paste and file uploads never become a source —
|
||||
// they stay inline []string on Config.Wordlist/Wordlists.
|
||||
//
|
||||
// It is one flat struct with a Kind discriminator, like Matcher/Filter; only the
|
||||
// fields a kind names are read. Count reports the resolved length without
|
||||
// allocating, so a caller can reject an oversized spec before Resolve builds it.
|
||||
type PayloadSource struct {
|
||||
Kind SourceKind `json:"kind"`
|
||||
|
||||
// builtin
|
||||
List string `json:"list,omitempty"`
|
||||
|
||||
// numbers
|
||||
NumberMode string `json:"numberMode,omitempty"` // "range" (default) | "digits"
|
||||
Min int64 `json:"min,omitempty"`
|
||||
Max int64 `json:"max,omitempty"`
|
||||
Step int64 `json:"step,omitempty"`
|
||||
MinDigits int `json:"minDigits,omitempty"`
|
||||
MaxDigits int `json:"maxDigits,omitempty"`
|
||||
Pad int `json:"pad,omitempty"`
|
||||
Hex bool `json:"hex,omitempty"`
|
||||
Upper bool `json:"upper,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
|
||||
// chars (charset x length) and lengths (repeated-char ramp, reuses MinLen/MaxLen/Step)
|
||||
Charset string `json:"charset,omitempty"`
|
||||
MinLen int `json:"minLen,omitempty"`
|
||||
MaxLen int `json:"maxLen,omitempty"`
|
||||
Char string `json:"char,omitempty"` // lengths: the repeated unit, default "A"
|
||||
|
||||
// dates
|
||||
DateStart string `json:"dateStart,omitempty"` // YYYY-MM-DD
|
||||
DateEnd string `json:"dateEnd,omitempty"`
|
||||
DateFmt string `json:"dateFmt,omitempty"` // Go reference layout, default "2006-01-02"
|
||||
DateStep int `json:"dateStep,omitempty"` // days, default 1
|
||||
}
|
||||
|
||||
// SourceKind names the shape of a PayloadSource.
|
||||
type SourceKind string
|
||||
|
||||
const (
|
||||
SourceBuiltin SourceKind = "builtin"
|
||||
SourceNumbers SourceKind = "numbers"
|
||||
SourceChars SourceKind = "chars"
|
||||
SourceLengths SourceKind = "lengths"
|
||||
SourceDates SourceKind = "dates"
|
||||
)
|
||||
|
||||
// MaxGeneratedPayloads caps the length of a single resolved source. It matches
|
||||
// the yolo product ceiling in handleFuzzerStart, so a generated position and a
|
||||
// cartesian run answer to the same order of magnitude.
|
||||
const MaxGeneratedPayloads = 10_000_000
|
||||
|
||||
const defaultDateLayout = "2006-01-02"
|
||||
|
||||
// Count returns how many payloads the source resolves to, without allocating
|
||||
// them, and validates the spec. A caller rejects the source when this exceeds
|
||||
// its budget rather than letting Resolve allocate it.
|
||||
func (s PayloadSource) Count() (int, error) {
|
||||
switch s.Kind {
|
||||
case SourceBuiltin:
|
||||
vals, ok := LookupBuiltin(s.List)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown built-in list %q", s.List)
|
||||
}
|
||||
return len(vals), nil
|
||||
case SourceNumbers:
|
||||
return s.numberCount()
|
||||
case SourceChars:
|
||||
return s.charsCount()
|
||||
case SourceLengths:
|
||||
n, _, err := s.lengthsBounds()
|
||||
return n, err
|
||||
case SourceDates:
|
||||
n, _, _, err := s.datesBounds()
|
||||
return n, err
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown source kind %q", s.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve materializes the source into a payload list, refusing a spec whose
|
||||
// Count exceeds limit.
|
||||
func (s PayloadSource) Resolve(limit int) ([]string, error) {
|
||||
n, err := s.Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("source produces no payloads")
|
||||
}
|
||||
if n > limit {
|
||||
return nil, fmt.Errorf("source would generate %d payloads (max %d)", n, limit)
|
||||
}
|
||||
switch s.Kind {
|
||||
case SourceBuiltin:
|
||||
vals, _ := LookupBuiltin(s.List)
|
||||
out := make([]string, len(vals))
|
||||
copy(out, vals)
|
||||
return out, nil
|
||||
case SourceNumbers:
|
||||
return s.resolveNumbers(n)
|
||||
case SourceChars:
|
||||
return s.resolveChars(n)
|
||||
case SourceLengths:
|
||||
return s.resolveLengths()
|
||||
case SourceDates:
|
||||
return s.resolveDates()
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown source kind %q", s.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// --- numbers ---
|
||||
|
||||
func (s PayloadSource) numberCount() (int, error) {
|
||||
if s.NumberMode == "digits" {
|
||||
return s.digitsCount()
|
||||
}
|
||||
step := s.Step
|
||||
if step == 0 {
|
||||
step = 1
|
||||
}
|
||||
if step < 0 {
|
||||
return 0, fmt.Errorf("numbers: step must be positive")
|
||||
}
|
||||
if s.Max < s.Min {
|
||||
return 0, fmt.Errorf("numbers: max must be >= min")
|
||||
}
|
||||
return int((s.Max-s.Min)/step) + 1, nil
|
||||
}
|
||||
|
||||
func (s PayloadSource) resolveNumbers(n int) ([]string, error) {
|
||||
if s.NumberMode == "digits" {
|
||||
return s.resolveDigits()
|
||||
}
|
||||
step := s.Step
|
||||
if step == 0 {
|
||||
step = 1
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for v := s.Min; v <= s.Max; v += step {
|
||||
out = append(out, s.formatNumber(v))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s PayloadSource) formatNumber(v int64) string {
|
||||
var body string
|
||||
if s.Hex {
|
||||
body = strconv.FormatInt(v, 16)
|
||||
if s.Upper {
|
||||
body = strings.ToUpper(body)
|
||||
}
|
||||
} else {
|
||||
body = strconv.FormatInt(v, 10)
|
||||
}
|
||||
if s.Pad > len(body) {
|
||||
body = strings.Repeat("0", s.Pad-len(body)) + body
|
||||
}
|
||||
return s.Prefix + body + s.Suffix
|
||||
}
|
||||
|
||||
// digits mode enumerates every number of each width w in [MinDigits, MaxDigits]
|
||||
// as a zero-padded run 0..10^w-1 — so "4 digits" is 0000..9999, the PIN/ID brute
|
||||
// pattern. Each width is fully enumerated with its own padding.
|
||||
func (s PayloadSource) digitsCount() (int, error) {
|
||||
if s.MinDigits < 1 || s.MaxDigits < s.MinDigits {
|
||||
return 0, fmt.Errorf("numbers: require 1 <= minDigits <= maxDigits")
|
||||
}
|
||||
if s.MaxDigits > 18 {
|
||||
return 0, fmt.Errorf("numbers: maxDigits too large")
|
||||
}
|
||||
total := 0
|
||||
for w := s.MinDigits; w <= s.MaxDigits; w++ {
|
||||
pow := 1
|
||||
for i := 0; i < w; i++ {
|
||||
pow *= 10
|
||||
if pow > MaxGeneratedPayloads {
|
||||
return pow, nil // caller rejects on the cap; stop before overflow
|
||||
}
|
||||
}
|
||||
total += pow
|
||||
if total > MaxGeneratedPayloads {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s PayloadSource) resolveDigits() ([]string, error) {
|
||||
n, err := s.digitsCount()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for w := s.MinDigits; w <= s.MaxDigits; w++ {
|
||||
hi := int64(1)
|
||||
for i := 0; i < w; i++ {
|
||||
hi *= 10
|
||||
}
|
||||
for v := int64(0); v < hi; v++ {
|
||||
body := strconv.FormatInt(v, 10)
|
||||
if w > len(body) {
|
||||
body = strings.Repeat("0", w-len(body)) + body
|
||||
}
|
||||
out = append(out, s.Prefix+body+s.Suffix)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- chars (charset x length) ---
|
||||
|
||||
func (s PayloadSource) charsCount() (int, error) {
|
||||
runes := []rune(s.Charset)
|
||||
if len(runes) == 0 {
|
||||
return 0, fmt.Errorf("chars: charset is empty")
|
||||
}
|
||||
if s.MinLen < 1 || s.MaxLen < s.MinLen {
|
||||
return 0, fmt.Errorf("chars: require 1 <= minLen <= maxLen")
|
||||
}
|
||||
total := 0
|
||||
for l := s.MinLen; l <= s.MaxLen; l++ {
|
||||
combos := 1
|
||||
for i := 0; i < l; i++ {
|
||||
combos *= len(runes)
|
||||
if combos > MaxGeneratedPayloads {
|
||||
return combos, nil
|
||||
}
|
||||
}
|
||||
total += combos
|
||||
if total > MaxGeneratedPayloads {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s PayloadSource) resolveChars(n int) ([]string, error) {
|
||||
runes := []rune(s.Charset)
|
||||
out := make([]string, 0, n)
|
||||
for l := s.MinLen; l <= s.MaxLen; l++ {
|
||||
idx := make([]int, l) // odometer over charset, position 0 is least significant
|
||||
for {
|
||||
var b strings.Builder
|
||||
for i := l - 1; i >= 0; i-- {
|
||||
b.WriteRune(runes[idx[i]])
|
||||
}
|
||||
out = append(out, b.String())
|
||||
// increment the odometer
|
||||
pos := 0
|
||||
for pos < l {
|
||||
idx[pos]++
|
||||
if idx[pos] < len(runes) {
|
||||
break
|
||||
}
|
||||
idx[pos] = 0
|
||||
pos++
|
||||
}
|
||||
if pos == l {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- lengths (repeated-char ramp) ---
|
||||
|
||||
func (s PayloadSource) lengthsBounds() (count, step int, err error) {
|
||||
step = int(s.Step)
|
||||
if step == 0 {
|
||||
step = 1
|
||||
}
|
||||
if step < 0 {
|
||||
return 0, 0, fmt.Errorf("lengths: step must be positive")
|
||||
}
|
||||
if s.MinLen < 1 || s.MaxLen < s.MinLen {
|
||||
return 0, 0, fmt.Errorf("lengths: require 1 <= minLen <= maxLen")
|
||||
}
|
||||
return (s.MaxLen-s.MinLen)/step + 1, step, nil
|
||||
}
|
||||
|
||||
func (s PayloadSource) resolveLengths() ([]string, error) {
|
||||
n, step, err := s.lengthsBounds()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unit := s.Char
|
||||
if unit == "" {
|
||||
unit = "A"
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for l := s.MinLen; l <= s.MaxLen; l += step {
|
||||
out = append(out, strings.Repeat(unit, l))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- dates ---
|
||||
|
||||
func (s PayloadSource) datesBounds() (count int, start time.Time, step int, err error) {
|
||||
start, err = time.Parse(defaultDateLayout, s.DateStart)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, 0, fmt.Errorf("dates: invalid dateStart (want YYYY-MM-DD)")
|
||||
}
|
||||
end, err := time.Parse(defaultDateLayout, s.DateEnd)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, 0, fmt.Errorf("dates: invalid dateEnd (want YYYY-MM-DD)")
|
||||
}
|
||||
if end.Before(start) {
|
||||
return 0, time.Time{}, 0, fmt.Errorf("dates: dateEnd must be >= dateStart")
|
||||
}
|
||||
step = s.DateStep
|
||||
if step == 0 {
|
||||
step = 1
|
||||
}
|
||||
if step < 0 {
|
||||
return 0, time.Time{}, 0, fmt.Errorf("dates: step must be positive")
|
||||
}
|
||||
days := int(end.Sub(start).Hours()/24) + 1
|
||||
return (days-1)/step + 1, start, step, nil
|
||||
}
|
||||
|
||||
func (s PayloadSource) resolveDates() ([]string, error) {
|
||||
n, start, step, err := s.datesBounds()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layout := s.DateFmt
|
||||
if layout == "" {
|
||||
layout = defaultDateLayout
|
||||
}
|
||||
end, _ := time.Parse(defaultDateLayout, s.DateEnd)
|
||||
out := make([]string, 0, n)
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, step) {
|
||||
out = append(out, d.Format(layout))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package fuzzer
|
||||
|
||||
// Built-in fuzzer wordlists. Go slice literals rather than embedded files,
|
||||
// following the repo's split: go:embed carries third-party or built artifacts,
|
||||
// while our own data is a slice literal (as internal/shell's dictionary and
|
||||
// internal/apispec's discovery lists are). These stay curated and compact —
|
||||
// large dictionaries (directory busting, credential lists) belong in an upload,
|
||||
// not baked into the binary.
|
||||
|
||||
// BuiltinList is a named, categorized payload list. Values is omitted from the
|
||||
// catalog wire shape; the catalog carries a count instead.
|
||||
type BuiltinList struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Values []string `json:"-"`
|
||||
}
|
||||
|
||||
// BuiltinLists returns the registry of built-in payload lists.
|
||||
func BuiltinLists() []BuiltinList {
|
||||
return []BuiltinList{
|
||||
{ID: "lfi", Label: "LFI / Path Traversal", Category: "injection",
|
||||
Description: "Local file inclusion and directory traversal probes", Values: lfiProbes},
|
||||
{ID: "sqli", Label: "SQL Injection", Category: "injection",
|
||||
Description: "Common SQL injection probe strings", Values: sqliProbes},
|
||||
{ID: "ssti", Label: "Template Injection", Category: "injection",
|
||||
Description: "Server-side template injection probes (expect 49 on reflection)", Values: sstiProbes},
|
||||
{ID: "xss", Label: "XSS Reflection", Category: "injection",
|
||||
Description: "Cross-site scripting reflection probes", Values: xssProbes},
|
||||
{ID: "metachars", Label: "Special Characters", Category: "fuzzing",
|
||||
Description: "Metacharacters that elicit errors or parsing differences", Values: metacharProbes},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupBuiltin returns a built-in list's values by id.
|
||||
func LookupBuiltin(id string) ([]string, bool) {
|
||||
for _, l := range BuiltinLists() {
|
||||
if l.ID == id {
|
||||
return l.Values, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GeneratorInfo describes a generator for the catalog. Its parameter forms are
|
||||
// bespoke UI, so only identity and help text travel.
|
||||
type GeneratorInfo struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Generators returns the catalog of parametric generators.
|
||||
func Generators() []GeneratorInfo {
|
||||
return []GeneratorInfo{
|
||||
{ID: string(SourceNumbers), Label: "Numbers", Description: "Numeric range (min/max/step) or every value of a digit width (e.g. 4-digit PINs)"},
|
||||
{ID: string(SourceChars), Label: "Characters", Description: "Every combination of a charset over a length range (brute force)"},
|
||||
{ID: string(SourceLengths), Label: "Length ramp", Description: "A character repeated at increasing lengths (buffer/boundary tests)"},
|
||||
{ID: string(SourceDates), Label: "Dates", Description: "A date range with a configurable format and step"},
|
||||
}
|
||||
}
|
||||
|
||||
var lfiProbes = []string{
|
||||
"../etc/passwd",
|
||||
"../../etc/passwd",
|
||||
"../../../etc/passwd",
|
||||
"../../../../etc/passwd",
|
||||
"../../../../../etc/passwd",
|
||||
"../../../../../../etc/passwd",
|
||||
"../../../../../../../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"....//....//....//etc/passwd",
|
||||
"..%2f..%2f..%2fetc%2fpasswd",
|
||||
"%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
||||
"..%252f..%252f..%252fetc%252fpasswd",
|
||||
"../../../etc/passwd%00",
|
||||
"/proc/self/environ",
|
||||
"/proc/self/cmdline",
|
||||
"php://filter/convert.base64-encode/resource=index.php",
|
||||
"file:///etc/passwd",
|
||||
"..\\..\\..\\windows\\win.ini",
|
||||
"..\\..\\..\\..\\windows\\win.ini",
|
||||
"C:\\windows\\win.ini",
|
||||
"..%5c..%5c..%5cwindows%5cwin.ini",
|
||||
"....\\\\....\\\\....\\\\windows\\win.ini",
|
||||
}
|
||||
|
||||
var sqliProbes = []string{
|
||||
"'",
|
||||
"\"",
|
||||
"`",
|
||||
"')",
|
||||
"\")",
|
||||
"';",
|
||||
"' OR '1'='1",
|
||||
"' OR '1'='1' -- ",
|
||||
"' OR '1'='1' #",
|
||||
"\" OR \"\"=\"",
|
||||
"' OR 1=1-- ",
|
||||
"') OR ('1'='1",
|
||||
"admin' -- ",
|
||||
"admin' #",
|
||||
"' UNION SELECT NULL-- ",
|
||||
"' UNION SELECT NULL,NULL-- ",
|
||||
"1' AND SLEEP(5)-- ",
|
||||
"' OR SLEEP(5)-- ",
|
||||
"1 AND 1=1",
|
||||
"1 AND 1=2",
|
||||
"1; WAITFOR DELAY '0:0:5'-- ",
|
||||
"') OR SLEEP(5)-- ",
|
||||
"1'||'1",
|
||||
"' AND extractvalue(1,concat(0x7e,version()))-- ",
|
||||
}
|
||||
|
||||
var sstiProbes = []string{
|
||||
"{{7*7}}",
|
||||
"${7*7}",
|
||||
"#{7*7}",
|
||||
"<%= 7*7 %>",
|
||||
"${{7*7}}",
|
||||
"#{ 7*7 }",
|
||||
"{{7*'7'}}",
|
||||
"{{ '7'*7 }}",
|
||||
"*{7*7}",
|
||||
"@(7*7)",
|
||||
"{7*7}",
|
||||
"${{<%[%'\"}}%\\.",
|
||||
"{{config}}",
|
||||
"{{self}}",
|
||||
"<%= system('id') %>",
|
||||
}
|
||||
|
||||
var xssProbes = []string{
|
||||
"<script>alert(1)</script>",
|
||||
"\"><script>alert(1)</script>",
|
||||
"'><script>alert(1)</script>",
|
||||
"\"><svg onload=alert(1)>",
|
||||
"'><svg onload=alert(1)>",
|
||||
"<svg/onload=alert(1)>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
"\"><img src=x onerror=alert(1)>",
|
||||
"javascript:alert(1)",
|
||||
"\" onmouseover=alert(1) x=\"",
|
||||
"' onmouseover='alert(1)",
|
||||
"<body onload=alert(1)>",
|
||||
"<iframe src=javascript:alert(1)>",
|
||||
"</script><script>alert(1)</script>",
|
||||
"<details open ontoggle=alert(1)>",
|
||||
}
|
||||
|
||||
var metacharProbes = []string{
|
||||
"'", "\"", "`", "\\", "<", ">", ";", "|", "&", "$", "%", "#", "@",
|
||||
"(", ")", "[", "]", "{", "}", "*", "?", "~", "!", "^", "+", "=",
|
||||
"$()", "${}", "`id`", "$(id)", "|id", ";id", "&&id", "||id",
|
||||
"%00", "%0a", "%0d", "%0d%0a", "{{", "}}", "\\n", "\\r\\n", "\x00",
|
||||
"../", "..\\", "' OR '1'='1", "<script>",
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { Tooltip } from '../Tooltip'
|
||||
import {
|
||||
type PayloadSource,
|
||||
type SourceKind,
|
||||
type SourceMode,
|
||||
type WordlistCatalog,
|
||||
defaultSource,
|
||||
sourceCount,
|
||||
} from '../../lib/fuzzSources'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
placeholder: string
|
||||
wordlist: string
|
||||
wordlistFileName: string
|
||||
source: PayloadSource | undefined
|
||||
catalog: WordlistCatalog | null
|
||||
disabled: boolean
|
||||
onWordlistChange: (v: string, fileName?: string) => void
|
||||
onSourceChange: (source: PayloadSource | undefined) => void
|
||||
}
|
||||
|
||||
function modeOf(source: PayloadSource | undefined): SourceMode {
|
||||
if (!source) return 'manual'
|
||||
return source.kind === 'builtin' ? 'builtin' : 'generator'
|
||||
}
|
||||
|
||||
const GENERATOR_KINDS: SourceKind[] = ['numbers', 'chars', 'lengths', 'dates']
|
||||
|
||||
export default function PayloadSourcePanel(props: Props) {
|
||||
const { label, placeholder, wordlist, wordlistFileName, source, catalog, disabled } = props
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const mode = modeOf(source)
|
||||
|
||||
const manualLines = wordlist.split('\n').filter((l) => l.trim() !== '').length
|
||||
const genCount = mode === 'manual' ? manualLines : sourceCount(source, catalog)
|
||||
|
||||
function setMode(next: SourceMode) {
|
||||
if (next === 'manual') props.onSourceChange(undefined)
|
||||
else if (next === 'builtin') props.onSourceChange({ kind: 'builtin', list: catalog?.builtins[0]?.id ?? '' })
|
||||
else props.onSourceChange(defaultSource('numbers'))
|
||||
}
|
||||
|
||||
function patch(p: Partial<PayloadSource>) {
|
||||
if (!source) return
|
||||
props.onSourceChange({ ...source, ...p })
|
||||
}
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => props.onWordlistChange(String(reader.result ?? ''), file.name)
|
||||
reader.readAsText(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden min-h-0 flex-1">
|
||||
{/* Header: label + count + mode selector */}
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-surface-card border-b border-border shrink-0">
|
||||
<span className="text-xs text-content-muted">
|
||||
{label} ({genCount < 0 ? '—' : genCount.toLocaleString()} payloads)
|
||||
</span>
|
||||
{mode === 'manual' && wordlistFileName && (
|
||||
<span className="text-xs text-content-secondary truncate max-w-32">{wordlistFileName}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5 ml-auto bg-surface-input rounded-sm p-0.5">
|
||||
{(['manual', 'builtin', 'generator'] as SourceMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
disabled={disabled}
|
||||
className={`text-xs px-2 py-0.5 rounded-sm capitalize ${
|
||||
mode === m ? 'bg-accent text-black' : 'text-content-secondary hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
{m === 'builtin' ? 'Built-in' : m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'manual' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-surface-card border-b border-border shrink-0">
|
||||
<label className="text-xs px-2 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary cursor-pointer">
|
||||
Upload
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".txt,.lst,.list,.wordlist,text/*"
|
||||
onChange={handleFile}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
{wordlist && (
|
||||
<Tooltip content="Clear wordlist">
|
||||
<button
|
||||
onClick={() => props.onWordlistChange('')}
|
||||
className="text-xs text-content-muted hover:text-semantic-error px-1 inline-flex items-center"
|
||||
disabled={disabled}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={wordlist}
|
||||
onChange={(e) => props.onWordlistChange(e.target.value)}
|
||||
className="flex-1 bg-surface-input text-xs font-mono px-2 py-1 resize-none outline-none text-content-primary"
|
||||
placeholder={placeholder}
|
||||
readOnly={disabled}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'builtin' && (
|
||||
<div className="flex-1 overflow-y-auto bg-surface-input p-3 flex flex-col gap-3">
|
||||
<BuiltinPicker catalog={catalog} source={source!} disabled={disabled} onPatch={patch} />
|
||||
<SourcePreview source={source} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'generator' && source && (
|
||||
<div className="flex-1 overflow-y-auto bg-surface-input p-3 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-content-muted w-20">Generator</span>
|
||||
<select
|
||||
value={source.kind}
|
||||
onChange={(e) => props.onSourceChange(defaultSource(e.target.value as SourceKind))}
|
||||
disabled={disabled}
|
||||
className="flex-1 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border"
|
||||
>
|
||||
{GENERATOR_KINDS.map((k) => {
|
||||
const info = catalog?.generators.find((g) => g.id === k)
|
||||
return <option key={k} value={k}>{info?.label ?? k}</option>
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<GeneratorFields source={source} disabled={disabled} onPatch={patch} />
|
||||
<SourcePreview source={source} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BuiltinPicker({ catalog, source, disabled, onPatch }: {
|
||||
catalog: WordlistCatalog | null
|
||||
source: PayloadSource
|
||||
disabled: boolean
|
||||
onPatch: (p: Partial<PayloadSource>) => void
|
||||
}) {
|
||||
const categories = Array.from(new Set((catalog?.builtins ?? []).map((b) => b.category)))
|
||||
const selected = catalog?.builtins.find((b) => b.id === source.list)
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-content-muted w-20">List</span>
|
||||
<select
|
||||
value={source.list ?? ''}
|
||||
onChange={(e) => onPatch({ list: e.target.value })}
|
||||
disabled={disabled}
|
||||
className="flex-1 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border"
|
||||
>
|
||||
<option value="">Select a list…</option>
|
||||
{categories.map((cat) => (
|
||||
<optgroup key={cat} label={cat}>
|
||||
{(catalog?.builtins ?? [])
|
||||
.filter((b) => b.category === cat)
|
||||
.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.label} ({b.count})</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selected && <p className="text-xs text-content-muted pl-20">{selected.description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GeneratorFields({ source, disabled, onPatch }: {
|
||||
source: PayloadSource
|
||||
disabled: boolean
|
||||
onPatch: (p: Partial<PayloadSource>) => void
|
||||
}) {
|
||||
if (source.kind === 'numbers') {
|
||||
const digits = source.numberMode === 'digits'
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-content-muted w-20">Mode</span>
|
||||
<div className="flex items-center gap-0.5 bg-surface-card rounded-sm p-0.5">
|
||||
{(['range', 'digits'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => onPatch({ numberMode: m })}
|
||||
disabled={disabled}
|
||||
className={`text-xs px-2 py-0.5 rounded-sm capitalize ${
|
||||
(source.numberMode ?? 'range') === m ? 'bg-accent text-black' : 'text-content-secondary hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{digits ? (
|
||||
<>
|
||||
<NumField label="Min digits" value={source.minDigits} disabled={disabled} onChange={(v) => onPatch({ minDigits: v })} />
|
||||
<NumField label="Max digits" value={source.maxDigits} disabled={disabled} onChange={(v) => onPatch({ maxDigits: v })} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NumField label="Min" value={source.min} disabled={disabled} onChange={(v) => onPatch({ min: v })} />
|
||||
<NumField label="Max" value={source.max} disabled={disabled} onChange={(v) => onPatch({ max: v })} />
|
||||
<NumField label="Step" value={source.step} disabled={disabled} onChange={(v) => onPatch({ step: v })} />
|
||||
<NumField label="Pad width" value={source.pad} disabled={disabled} onChange={(v) => onPatch({ pad: v })} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-content-muted w-20">Base</span>
|
||||
<label className="text-xs text-content-secondary flex items-center gap-1">
|
||||
<input type="checkbox" checked={!!source.hex} disabled={disabled} onChange={(e) => onPatch({ hex: e.target.checked })} />
|
||||
Hexadecimal
|
||||
</label>
|
||||
{source.hex && (
|
||||
<label className="text-xs text-content-secondary flex items-center gap-1">
|
||||
<input type="checkbox" checked={!!source.upper} disabled={disabled} onChange={(e) => onPatch({ upper: e.target.checked })} />
|
||||
Uppercase
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TextField label="Prefix" value={source.prefix} disabled={disabled} onChange={(v) => onPatch({ prefix: v })} />
|
||||
<TextField label="Suffix" value={source.suffix} disabled={disabled} onChange={(v) => onPatch({ suffix: v })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (source.kind === 'chars') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextField label="Charset" value={source.charset} disabled={disabled} onChange={(v) => onPatch({ charset: v })} mono />
|
||||
<NumField label="Min length" value={source.minLen} disabled={disabled} onChange={(v) => onPatch({ minLen: v })} />
|
||||
<NumField label="Max length" value={source.maxLen} disabled={disabled} onChange={(v) => onPatch({ maxLen: v })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (source.kind === 'lengths') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextField label="Character" value={source.char} disabled={disabled} onChange={(v) => onPatch({ char: v })} mono />
|
||||
<NumField label="Min length" value={source.minLen} disabled={disabled} onChange={(v) => onPatch({ minLen: v })} />
|
||||
<NumField label="Max length" value={source.maxLen} disabled={disabled} onChange={(v) => onPatch({ maxLen: v })} />
|
||||
<NumField label="Step" value={source.step} disabled={disabled} onChange={(v) => onPatch({ step: v })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// dates
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextField label="Start (Y-M-D)" value={source.dateStart} disabled={disabled} onChange={(v) => onPatch({ dateStart: v })} mono />
|
||||
<TextField label="End (Y-M-D)" value={source.dateEnd} disabled={disabled} onChange={(v) => onPatch({ dateEnd: v })} mono />
|
||||
<NumField label="Step (days)" value={source.dateStep} disabled={disabled} onChange={(v) => onPatch({ dateStep: v })} />
|
||||
<TextField label="Format" value={source.dateFmt} disabled={disabled} onChange={(v) => onPatch({ dateFmt: v })} mono placeholder="2006-01-02" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NumField({ label, value, disabled, onChange }: {
|
||||
label: string; value: number | undefined; disabled: boolean; onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-content-muted w-20">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
className="w-28 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({ label, value, disabled, onChange, mono, placeholder }: {
|
||||
label: string; value: string | undefined; disabled: boolean; onChange: (v: string) => void; mono?: boolean; placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-content-muted w-20">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
className={`flex-1 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border outline-none ${mono ? 'font-mono' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SourcePreview debounces a call to the resolver so the operator sees the real
|
||||
// count and the first generated values, straight from the server that will run them.
|
||||
function SourcePreview({ source }: { source: PayloadSource | undefined }) {
|
||||
const [state, setState] = useState<{ count: number; sample: string[]; exceedsLimit: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) { setState(null); setError(null); return }
|
||||
let cancelled = false
|
||||
const t = setTimeout(() => {
|
||||
api.fuzzPreviewSource(source)
|
||||
.then((res) => { if (!cancelled) { setState(res); setError(null) } })
|
||||
.catch((e) => { if (!cancelled) { setState(null); setError(e?.message || 'invalid source') } })
|
||||
}, 300)
|
||||
return () => { cancelled = true; clearTimeout(t) }
|
||||
}, [source])
|
||||
|
||||
if (error) return <p className="text-xs text-semantic-error">{error}</p>
|
||||
if (!state) return <p className="text-xs text-content-muted">Preview…</p>
|
||||
return (
|
||||
<div className="text-xs text-content-muted border-t border-border-subtle pt-2">
|
||||
<span className={state.exceedsLimit ? 'text-semantic-error' : 'text-content-secondary'}>
|
||||
≈ {state.count.toLocaleString()} payloads
|
||||
{state.exceedsLimit && ' (exceeds the 10,000,000 limit)'}
|
||||
</span>
|
||||
{state.sample.length > 0 && (
|
||||
<span className="font-mono"> — {state.sample.slice(0, 8).map((s) => s || '∅').join(', ')}…</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
EchoState,
|
||||
} from './echoTypes'
|
||||
import type { InterceptKind, PendingItem } from '../stores/interceptStore'
|
||||
import type { PayloadSource, WordlistCatalog } from './fuzzSources'
|
||||
import type { ChatMessage, ActiveUser } from '../stores/teamStore'
|
||||
import type { FlaggedSummary, FlaggedRequest } from '../stores/teamFlaggedStore'
|
||||
import type { SharedConfigSummary, SharedConfig, SharedConfigPayload } from '../stores/teamSharedConfigStore'
|
||||
@@ -1181,6 +1182,8 @@ export const api = {
|
||||
raw: string; scheme: string; host: string;
|
||||
wordlist?: string[];
|
||||
wordlists?: Record<string, string[]>;
|
||||
source?: PayloadSource;
|
||||
sources?: Record<string, PayloadSource>;
|
||||
attackMode?: string;
|
||||
threads: number; rateLimit: number; followRedirects: boolean;
|
||||
updateContentLength?: boolean;
|
||||
@@ -1190,6 +1193,9 @@ export const api = {
|
||||
matcherMode: string; filterMode: string;
|
||||
maxStoredBodies?: number;
|
||||
}) => req<{ campaignId: string; total: number }>('POST', '/fuzzer/start', params),
|
||||
fuzzListWordlists: () => req<WordlistCatalog>('GET', '/fuzzer/wordlists'),
|
||||
fuzzPreviewSource: (source: PayloadSource) =>
|
||||
req<{ count: number; sample: string[]; exceedsLimit: boolean }>('POST', '/fuzzer/wordlist/preview', source),
|
||||
fuzzStop: (id: string) => req<{ status: string }>('POST', `/fuzzer/${id}/stop`),
|
||||
fuzzListCampaigns: () => req<{ campaigns: Array<{ id: string; status: string; createdAt: string; total: number; completed: number; errors: number }> }>('GET', '/fuzzer/campaigns'),
|
||||
fuzzGetCampaign: (id: string, offset?: number, limit?: number) => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Payload-source model for the Fuzzer, mirroring internal/fuzzer/sources.go.
|
||||
//
|
||||
// A source is a compact spec (a built-in list id, or generator parameters) that
|
||||
// the server resolves into payloads, so a huge numeric range never travels inline.
|
||||
// `sourceCount` is a client-side mirror of the Go Count() for instant estimates;
|
||||
// the server is authoritative and rejects anything over the cap, so drift only
|
||||
// mis-estimates and self-heals on start. Keep the two in sync.
|
||||
|
||||
export type SourceKind = 'builtin' | 'numbers' | 'chars' | 'lengths' | 'dates'
|
||||
|
||||
// How a position gets its payloads in the UI. 'manual' is the textarea/upload path
|
||||
// and never becomes a PayloadSource — it travels inline as string[].
|
||||
export type SourceMode = 'manual' | 'builtin' | 'generator'
|
||||
|
||||
export interface PayloadSource {
|
||||
kind: SourceKind
|
||||
// builtin
|
||||
list?: string
|
||||
// numbers
|
||||
numberMode?: 'range' | 'digits'
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
minDigits?: number
|
||||
maxDigits?: number
|
||||
pad?: number
|
||||
hex?: boolean
|
||||
upper?: boolean
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
// chars / lengths
|
||||
charset?: string
|
||||
minLen?: number
|
||||
maxLen?: number
|
||||
char?: string
|
||||
// dates
|
||||
dateStart?: string
|
||||
dateEnd?: string
|
||||
dateFmt?: string
|
||||
dateStep?: number
|
||||
}
|
||||
|
||||
export interface BuiltinInfo {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
category: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface GeneratorInfo {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface WordlistCatalog {
|
||||
builtins: BuiltinInfo[]
|
||||
generators: GeneratorInfo[]
|
||||
}
|
||||
|
||||
export const MAX_GENERATED_PAYLOADS = 10_000_000
|
||||
|
||||
// Default parameters when a generator is first selected.
|
||||
export function defaultSource(kind: SourceKind): PayloadSource {
|
||||
switch (kind) {
|
||||
case 'numbers':
|
||||
return { kind, numberMode: 'range', min: 0, max: 100, step: 1, pad: 0 }
|
||||
case 'chars':
|
||||
return { kind, charset: 'abcdefghijklmnopqrstuvwxyz', minLen: 1, maxLen: 2 }
|
||||
case 'lengths':
|
||||
return { kind, char: 'A', minLen: 1, maxLen: 100, step: 1 }
|
||||
case 'dates':
|
||||
return { kind, dateStart: '2020-01-01', dateEnd: '2020-12-31', dateFmt: '2006-01-02', dateStep: 1 }
|
||||
case 'builtin':
|
||||
return { kind, list: '' }
|
||||
}
|
||||
}
|
||||
|
||||
// sourceCount mirrors Go PayloadSource.Count(): the resolved length, or -1 when the
|
||||
// spec is invalid/incomplete (so the UI can disable Start without throwing).
|
||||
export function sourceCount(src: PayloadSource | undefined, catalog: WordlistCatalog | null): number {
|
||||
if (!src) return -1
|
||||
switch (src.kind) {
|
||||
case 'builtin': {
|
||||
const found = catalog?.builtins.find(b => b.id === src.list)
|
||||
return found ? found.count : -1
|
||||
}
|
||||
case 'numbers': {
|
||||
if (src.numberMode === 'digits') {
|
||||
const lo = src.minDigits ?? 0
|
||||
const hi = src.maxDigits ?? 0
|
||||
if (lo < 1 || hi < lo || hi > 18) return -1
|
||||
let total = 0
|
||||
for (let w = lo; w <= hi; w++) {
|
||||
total += Math.pow(10, w)
|
||||
if (total > MAX_GENERATED_PAYLOADS) return total
|
||||
}
|
||||
return total
|
||||
}
|
||||
const step = src.step && src.step !== 0 ? src.step : 1
|
||||
const min = src.min ?? 0
|
||||
const max = src.max ?? 0
|
||||
if (step < 0 || max < min) return -1
|
||||
return Math.floor((max - min) / step) + 1
|
||||
}
|
||||
case 'chars': {
|
||||
const n = (src.charset ?? '').length
|
||||
const lo = src.minLen ?? 0
|
||||
const hi = src.maxLen ?? 0
|
||||
if (n === 0 || lo < 1 || hi < lo) return -1
|
||||
let total = 0
|
||||
for (let l = lo; l <= hi; l++) {
|
||||
total += Math.pow(n, l)
|
||||
if (total > MAX_GENERATED_PAYLOADS) return total
|
||||
}
|
||||
return total
|
||||
}
|
||||
case 'lengths': {
|
||||
const step = src.step && src.step !== 0 ? src.step : 1
|
||||
const lo = src.minLen ?? 0
|
||||
const hi = src.maxLen ?? 0
|
||||
if (step < 0 || lo < 1 || hi < lo) return -1
|
||||
return Math.floor((hi - lo) / step) + 1
|
||||
}
|
||||
case 'dates': {
|
||||
const start = Date.parse(src.dateStart ?? '')
|
||||
const end = Date.parse(src.dateEnd ?? '')
|
||||
const step = src.dateStep && src.dateStep !== 0 ? src.dateStep : 1
|
||||
if (isNaN(start) || isNaN(end) || end < start || step < 0) return -1
|
||||
const days = Math.floor((end - start) / 86_400_000) + 1
|
||||
return Math.floor((days - 1) / step) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-149
@@ -17,6 +17,8 @@ import { ResponseRender, usePrettyJson } from '../components/ResponseRender'
|
||||
import LensOutput from '../components/LensOutput'
|
||||
import TabButton from '../components/TabButton'
|
||||
import { useLenses } from '../lib/lenses'
|
||||
import PayloadSourcePanel from '../components/fuzz/PayloadSourcePanel'
|
||||
import { sourceCount, type WordlistCatalog } from '../lib/fuzzSources'
|
||||
|
||||
function b64Encode(s: string) { try { return btoa(s) } catch { return s } }
|
||||
function b64Decode(s: string) { try { return atob(s) } catch { return s } }
|
||||
@@ -169,10 +171,14 @@ export default function Fuzz() {
|
||||
const { tabs, activeTabId, addTab, removeTab, renameTab, setActiveTab, ...store } = useFuzzStore()
|
||||
const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const posFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
|
||||
// Built-in list + generator catalog, fetched once.
|
||||
const [catalog, setCatalog] = useState<WordlistCatalog | null>(null)
|
||||
useEffect(() => {
|
||||
api.fuzzListWordlists().then(setCatalog).catch(() => setCatalog(null))
|
||||
}, [])
|
||||
|
||||
// Tab rename state
|
||||
const [editingTabId, setEditingTabId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
@@ -268,35 +274,39 @@ export default function Fuzz() {
|
||||
return tab.wordlist.split('\n').filter(l => l.trim() !== '')
|
||||
}, [tab.wordlist])
|
||||
|
||||
// Payload count for a position, honoring its source (built-in/generator) over
|
||||
// the manual textarea. A source with an invalid/incomplete spec counts as 0.
|
||||
const singleCount = tab.source ? Math.max(0, sourceCount(tab.source, catalog)) : wordlistLines.length
|
||||
const positionCount = useCallback((pw: { wordlist: string; source?: Parameters<typeof sourceCount>[0] }) => {
|
||||
if (pw.source) return Math.max(0, sourceCount(pw.source, catalog))
|
||||
return pw.wordlist.split('\n').filter(l => l.trim() !== '').length
|
||||
}, [catalog])
|
||||
|
||||
// Determine if we can start based on wordlist state
|
||||
const canStart = useMemo(() => {
|
||||
if (detectedPositions.length === 0) return false
|
||||
if (!isMultiPosition) return wordlistLines.length > 0
|
||||
if (tab.attackMode === 'spray') return wordlistLines.length > 0
|
||||
// split/yolo: all position wordlists must have content
|
||||
return tab.positionWordlists.every(pw => pw.wordlist.split('\n').some(l => l.trim() !== ''))
|
||||
}, [detectedPositions, isMultiPosition, wordlistLines, tab.attackMode, tab.positionWordlists])
|
||||
if (!isMultiPosition) return singleCount > 0
|
||||
if (tab.attackMode === 'spray') return singleCount > 0
|
||||
// split/yolo: every position must have content
|
||||
return tab.positionWordlists.every(pw => positionCount(pw) > 0)
|
||||
}, [detectedPositions, isMultiPosition, singleCount, tab.attackMode, tab.positionWordlists, positionCount])
|
||||
|
||||
// Estimated total for display
|
||||
const estimatedTotal = useMemo(() => {
|
||||
if (!canStart) return 0
|
||||
if (!isMultiPosition) return wordlistLines.length
|
||||
if (!isMultiPosition) return singleCount
|
||||
switch (tab.attackMode) {
|
||||
case 'spray':
|
||||
return wordlistLines.length
|
||||
case 'split': {
|
||||
const lengths = tab.positionWordlists.map(pw => pw.wordlist.split('\n').filter(l => l.trim()).length)
|
||||
return Math.min(...lengths)
|
||||
}
|
||||
return singleCount
|
||||
case 'split':
|
||||
return Math.min(...tab.positionWordlists.map(positionCount))
|
||||
case 'yolo': {
|
||||
let product = 1
|
||||
for (const pw of tab.positionWordlists) {
|
||||
product *= pw.wordlist.split('\n').filter(l => l.trim()).length
|
||||
}
|
||||
for (const pw of tab.positionWordlists) product *= positionCount(pw)
|
||||
return product
|
||||
}
|
||||
}
|
||||
}, [canStart, isMultiPosition, wordlistLines, tab.attackMode, tab.positionWordlists])
|
||||
}, [canStart, isMultiPosition, singleCount, tab.attackMode, tab.positionWordlists, positionCount])
|
||||
|
||||
// Compute speed
|
||||
const speedRef = useRef({ times: [] as number[] })
|
||||
@@ -387,28 +397,31 @@ export default function Fuzz() {
|
||||
|
||||
let res: { campaignId: string; total: number }
|
||||
if (!isMultiPosition) {
|
||||
// Single-position
|
||||
// Single-position: a source resolves server-side, else send the manual lines.
|
||||
res = await api.fuzzStart({
|
||||
...baseParams,
|
||||
wordlist: wordlistLines,
|
||||
...(tab.source ? { source: tab.source } : { wordlist: wordlistLines }),
|
||||
fuzzKeyword: tab.fuzzKeyword || 'FUZZ',
|
||||
})
|
||||
} else if (tab.attackMode === 'spray') {
|
||||
// Multi-position spray: same wordlist for all
|
||||
// Multi-position spray: one source/wordlist used for all positions.
|
||||
res = await api.fuzzStart({
|
||||
...baseParams,
|
||||
wordlist: wordlistLines,
|
||||
...(tab.source ? { source: tab.source } : { wordlist: wordlistLines }),
|
||||
attackMode: 'spray',
|
||||
})
|
||||
} else {
|
||||
// Multi-position split/yolo: per-position wordlists
|
||||
// Multi-position split/yolo: each position sends a source or inline lines.
|
||||
const wordlists: Record<string, string[]> = {}
|
||||
const sources: Record<string, NonNullable<typeof tab.source>> = {}
|
||||
for (const pw of tab.positionWordlists) {
|
||||
wordlists[pw.position] = pw.wordlist.split('\n').filter(l => l.trim() !== '')
|
||||
if (pw.source) sources[pw.position] = pw.source
|
||||
else wordlists[pw.position] = pw.wordlist.split('\n').filter(l => l.trim() !== '')
|
||||
}
|
||||
res = await api.fuzzStart({
|
||||
...baseParams,
|
||||
wordlists,
|
||||
...(Object.keys(wordlists).length ? { wordlists } : {}),
|
||||
...(Object.keys(sources).length ? { sources } : {}),
|
||||
attackMode: tab.attackMode,
|
||||
})
|
||||
}
|
||||
@@ -428,24 +441,6 @@ export default function Fuzz() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => store.setWordlist(reader.result as string, file.name)
|
||||
reader.readAsText(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function handlePositionFileUpload(position: string, e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => store.setPositionWordlist(position, reader.result as string, file.name)
|
||||
reader.readAsText(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function addFuzzLocation() {
|
||||
const view = editorViewRef.current
|
||||
if (!view) return
|
||||
@@ -798,119 +793,53 @@ export default function Fuzz() {
|
||||
{/* Wordlist panel */}
|
||||
<div className="flex flex-col overflow-hidden min-h-0" style={{ flex: 1 - hSplit.fraction }}>
|
||||
{showSingleWordlist ? (
|
||||
<>
|
||||
{/* Single wordlist header */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-surface-card border-b border-border shrink-0">
|
||||
<span className="text-xs text-content-muted">
|
||||
Wordlist ({formatNumber(wordlistLines.length)} payloads)
|
||||
</span>
|
||||
{tab.wordlistFileName && (
|
||||
<span className="text-xs text-content-secondary ml-1 truncate max-w-32">{tab.wordlistFileName}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<label className="text-xs px-2 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary cursor-pointer">
|
||||
Upload
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.lst,.list,.wordlist,text/*"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</label>
|
||||
{tab.wordlist && (
|
||||
<Tooltip content="Clear wordlist">
|
||||
<button
|
||||
onClick={() => store.setWordlist('')}
|
||||
className="text-xs text-content-muted hover:text-semantic-error px-1 inline-flex items-center"
|
||||
disabled={isRunning}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={tab.wordlist}
|
||||
onChange={(e) => store.setWordlist(e.target.value)}
|
||||
className="flex-1 bg-surface-input text-xs font-mono px-2 py-1 resize-none outline-none text-content-primary"
|
||||
placeholder="Paste wordlist here (one payload per line) or upload a file..."
|
||||
readOnly={isRunning}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</>
|
||||
<PayloadSourcePanel
|
||||
label="Wordlist"
|
||||
placeholder="Paste wordlist here (one payload per line) or upload a file..."
|
||||
wordlist={tab.wordlist}
|
||||
wordlistFileName={tab.wordlistFileName}
|
||||
source={tab.source}
|
||||
catalog={catalog}
|
||||
disabled={isRunning}
|
||||
onWordlistChange={(v, fn) => store.setWordlist(v, fn)}
|
||||
onSourceChange={(src) => store.setSource(src)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Multi-position wordlist tabs */}
|
||||
<div className="flex items-center bg-surface-card border-b border-border shrink-0 overflow-x-auto">
|
||||
{tab.positionWordlists.map((pw, idx) => {
|
||||
const lines = pw.wordlist.split('\n').filter(l => l.trim()).length
|
||||
return (
|
||||
<button
|
||||
key={pw.position}
|
||||
onClick={() => store.setSelectedPositionTab(pw.position)}
|
||||
className={`flex items-center gap-1 px-3 py-1 text-xs border-b-2 shrink-0 ${
|
||||
activeWlTab === pw.position
|
||||
? 'text-content-primary border-accent'
|
||||
: 'text-content-muted hover:text-content-secondary border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-sm"
|
||||
style={{ background: positionTabColor(idx) }}
|
||||
/>
|
||||
{pw.position}
|
||||
<span className="text-[10px] text-content-muted">({lines})</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{tab.positionWordlists.map((pw, idx) => (
|
||||
<button
|
||||
key={pw.position}
|
||||
onClick={() => store.setSelectedPositionTab(pw.position)}
|
||||
className={`flex items-center gap-1 px-3 py-1 text-xs border-b-2 shrink-0 ${
|
||||
activeWlTab === pw.position
|
||||
? 'text-content-primary border-accent'
|
||||
: 'text-content-muted hover:text-content-secondary border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-sm"
|
||||
style={{ background: positionTabColor(idx) }}
|
||||
/>
|
||||
{pw.position}
|
||||
<span className="text-[10px] text-content-muted">({formatNumber(positionCount(pw))})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Active position wordlist */}
|
||||
{activePositionWl && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-surface-card border-b border-border shrink-0">
|
||||
<span className="text-xs text-content-muted">
|
||||
{activeWlTab} ({formatNumber(activePositionWl.wordlist.split('\n').filter(l => l.trim()).length)} payloads)
|
||||
</span>
|
||||
{activePositionWl.wordlistFileName && (
|
||||
<span className="text-xs text-content-secondary ml-1 truncate max-w-32">{activePositionWl.wordlistFileName}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<label className="text-xs px-2 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary cursor-pointer">
|
||||
Upload
|
||||
<input
|
||||
ref={posFileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.lst,.list,.wordlist,text/*"
|
||||
onChange={(e) => handlePositionFileUpload(activeWlTab, e)}
|
||||
className="hidden"
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</label>
|
||||
{activePositionWl.wordlist && (
|
||||
<Tooltip content="Clear wordlist">
|
||||
<button
|
||||
onClick={() => store.setPositionWordlist(activeWlTab, '')}
|
||||
className="text-xs text-content-muted hover:text-semantic-error px-1 inline-flex items-center"
|
||||
disabled={isRunning}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={activePositionWl.wordlist}
|
||||
onChange={(e) => store.setPositionWordlist(activeWlTab, e.target.value)}
|
||||
className="flex-1 bg-surface-input text-xs font-mono px-2 py-1 resize-none outline-none text-content-primary"
|
||||
placeholder={`Paste wordlist for ${activeWlTab} (one payload per line) or upload a file...`}
|
||||
readOnly={isRunning}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</>
|
||||
<PayloadSourcePanel
|
||||
key={activeWlTab}
|
||||
label={activeWlTab}
|
||||
placeholder={`Paste wordlist for ${activeWlTab} (one payload per line) or upload a file...`}
|
||||
wordlist={activePositionWl.wordlist}
|
||||
wordlistFileName={activePositionWl.wordlistFileName}
|
||||
source={activePositionWl.source}
|
||||
catalog={catalog}
|
||||
disabled={isRunning}
|
||||
onWordlistChange={(v, fn) => store.setPositionWordlist(activeWlTab, v, fn)}
|
||||
onSourceChange={(src) => store.setPositionSource(activeWlTab, src)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { api } from '../lib/api'
|
||||
import type { PayloadSource } from '../lib/fuzzSources'
|
||||
|
||||
export interface FuzzResult {
|
||||
index: number
|
||||
@@ -50,6 +51,9 @@ export interface PositionWordlist {
|
||||
position: string
|
||||
wordlist: string
|
||||
wordlistFileName: string
|
||||
// A built-in list or generator, resolved server-side. Undefined = manual
|
||||
// (use the wordlist text above). source.kind distinguishes built-in vs generator.
|
||||
source?: PayloadSource
|
||||
}
|
||||
|
||||
export interface FuzzTab {
|
||||
@@ -64,6 +68,8 @@ export interface FuzzTab {
|
||||
// Wordlist (single-position)
|
||||
wordlist: string
|
||||
wordlistFileName: string
|
||||
// Single-position / spray payload source. Undefined = manual (use wordlist text).
|
||||
source?: PayloadSource
|
||||
|
||||
// Multi-position wordlists
|
||||
positionWordlists: PositionWordlist[]
|
||||
@@ -126,6 +132,8 @@ interface FuzzState {
|
||||
setRawReq: (v: string) => void
|
||||
setWordlist: (v: string, fileName?: string) => void
|
||||
setPositionWordlist: (position: string, wordlist: string, fileName?: string) => void
|
||||
setSource: (source: PayloadSource | undefined) => void
|
||||
setPositionSource: (position: string, source: PayloadSource | undefined) => void
|
||||
setAttackMode: (mode: AttackMode) => void
|
||||
setSelectedPositionTab: (tab: string) => void
|
||||
syncPositionWordlists: (positions: string[]) => void
|
||||
@@ -279,6 +287,16 @@ export const useFuzzStore = create<FuzzState>((set) => ({
|
||||
),
|
||||
})
|
||||
}),
|
||||
setSource: (source) => set((s) => updateActiveTab(s, { source })),
|
||||
setPositionSource: (position, source) => set((s) => {
|
||||
const tab = s.tabs.find((t) => t.id === s.activeTabId)
|
||||
if (!tab) return s
|
||||
return updateActiveTab(s, {
|
||||
positionWordlists: tab.positionWordlists.map((pw) =>
|
||||
pw.position === position ? { ...pw, source } : pw
|
||||
),
|
||||
})
|
||||
}),
|
||||
setAttackMode: (mode) => set((s) => updateActiveTab(s, { attackMode: mode })),
|
||||
setSelectedPositionTab: (tab) => set((s) => updateActiveTab(s, { selectedPositionTab: tab })),
|
||||
syncPositionWordlists: (positions) => set((s) => {
|
||||
|
||||
Reference in new issue
Block a user