441 lines
16 KiB
Go
441 lines
16 KiB
Go
package tui
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/charmbracelet/bubbles/textarea"
|
||
"github.com/charmbracelet/bubbles/textinput"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
|
||
"github.com/r3g1tpr0cs/burterm/internal/cli"
|
||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||
"github.com/r3g1tpr0cs/burterm/internal/payload"
|
||
)
|
||
|
||
// requestBuilderModel — форма ввода: сырой HTTP-запрос с §маркерами§,
|
||
// режим атаки, спецификации payload'ов (по одной на точку вставки,
|
||
// в порядке §маркеров§) и параметры движка.
|
||
type requestBuilderModel struct {
|
||
textarea textarea.Model
|
||
|
||
mode engine.AttackMode
|
||
|
||
payloadInputs []textinput.Model
|
||
|
||
concurrency textinput.Model
|
||
rateLimitMs textinput.Model
|
||
timeoutSec textinput.Model
|
||
insecure bool
|
||
scheme string // "https" | "http" — см. комментарий к engine.Config.ForceScheme
|
||
|
||
// focusIdx: 0 — textarea, 1..len(payloadInputs) — поля payload'ов,
|
||
// затем concurrency, rateLimitMs, timeoutSec.
|
||
focusIdx int
|
||
errMsg string
|
||
|
||
statusMsg string // информационная строка (не ошибка) — напр. что предложила ctrl+n/ctrl+l
|
||
presetCycleIdx int // текущая позиция обзора заготовок по ctrl+l
|
||
|
||
lastSentRaw string // содержимое textarea на момент последнего ctrl+r — для звёздочки "есть несохранённые правки"
|
||
|
||
// recursionEnabled — включает авто-рекурсию в найденные директории
|
||
// (аналог -recursion у ffuf, см. комментарий у recursionMeta в model.go).
|
||
// Работает только когда в запросе ровно один §маркер§ — при нескольких
|
||
// точках вставки неясно, по какой из них углубляться, а городить ради
|
||
// этого отдельный UI-селектор точки не стали ради простоты.
|
||
recursionEnabled bool
|
||
}
|
||
|
||
// defaultRecursionStatuses/MaxDepth — параметры рекурсии не вынесены
|
||
// в отдельные поля формы (и так уже 9+ полей на экране) — фиксированные
|
||
// разумные дефолты, как первое приближение. Статусы совпадают с тем,
|
||
// что ffuf по умолчанию считает "нашли что-то, стоит углубиться".
|
||
const (
|
||
defaultRecursionStatuses = "200,301,302,403"
|
||
defaultRecursionMaxDepth = 2
|
||
)
|
||
|
||
func newRequestBuilder() requestBuilderModel {
|
||
ta := textarea.New()
|
||
ta.Placeholder = "GET /api/user?id=§1§ HTTP/1.1\nHost: example.com\n\n"
|
||
ta.ShowLineNumbers = true
|
||
ta.Focus()
|
||
|
||
payloadIn := textinput.New()
|
||
payloadIn.Placeholder = "wordlist:путь | range:1:100:1 | preset:numbers-small | ctrl+n/ctrl+l"
|
||
payloadIn.CharLimit = 256
|
||
|
||
concurrency := textinput.New()
|
||
concurrency.Placeholder = "10"
|
||
concurrency.CharLimit = 6
|
||
|
||
rateLimit := textinput.New()
|
||
rateLimit.Placeholder = "0"
|
||
rateLimit.CharLimit = 8
|
||
|
||
timeout := textinput.New()
|
||
timeout.Placeholder = "10"
|
||
timeout.CharLimit = 6
|
||
|
||
return requestBuilderModel{
|
||
textarea: ta,
|
||
mode: engine.ModeSniper,
|
||
scheme: "https",
|
||
payloadInputs: []textinput.Model{payloadIn},
|
||
concurrency: concurrency,
|
||
rateLimitMs: rateLimit,
|
||
timeoutSec: timeout,
|
||
}
|
||
}
|
||
|
||
// totalFocusable — сколько полей можно обойти табом: textarea +
|
||
// поля payload'ов + concurrency/rateLimit/timeout.
|
||
func (m *requestBuilderModel) totalFocusable() int {
|
||
return 1 + len(m.payloadInputs) + 3
|
||
}
|
||
|
||
// applyFocus снимает фокус со всех полей и ставит его на m.focusIdx.
|
||
// Возвращаемые Focus() команды (мигание курсора) осознанно отбрасываются
|
||
// ради простоты — не влияет на функциональность, только на анимацию.
|
||
func (m *requestBuilderModel) applyFocus() {
|
||
m.textarea.Blur()
|
||
for i := range m.payloadInputs {
|
||
m.payloadInputs[i].Blur()
|
||
}
|
||
m.concurrency.Blur()
|
||
m.rateLimitMs.Blur()
|
||
m.timeoutSec.Blur()
|
||
|
||
switch {
|
||
case m.focusIdx == 0:
|
||
m.textarea.Focus()
|
||
case m.focusIdx <= len(m.payloadInputs):
|
||
m.payloadInputs[m.focusIdx-1].Focus()
|
||
case m.focusIdx == len(m.payloadInputs)+1:
|
||
m.concurrency.Focus()
|
||
case m.focusIdx == len(m.payloadInputs)+2:
|
||
m.rateLimitMs.Focus()
|
||
default:
|
||
m.timeoutSec.Focus()
|
||
}
|
||
}
|
||
|
||
// IsModified — правки в textarea после последней отправки (ctrl+r) есть?
|
||
// Используется корневой моделью для звёздочки в названии вкладки [f1].
|
||
// Сравнение с изначально пустым lastSentRaw тоже корректно: если текст
|
||
// набран, но ни разу не отправлен, это и есть несохранённые правки.
|
||
func (m requestBuilderModel) IsModified() bool {
|
||
return m.textarea.Value() != m.lastSentRaw
|
||
}
|
||
|
||
func modeName(mode engine.AttackMode) string {
|
||
switch mode {
|
||
case engine.ModeSniper:
|
||
return "sniper"
|
||
case engine.ModeBattering:
|
||
return "battering"
|
||
case engine.ModePitchfork:
|
||
return "pitchfork"
|
||
case engine.ModeClusterBomb:
|
||
return "clusterbomb"
|
||
default:
|
||
return "?"
|
||
}
|
||
}
|
||
|
||
func nextMode(mode engine.AttackMode) engine.AttackMode {
|
||
return (mode + 1) % 4
|
||
}
|
||
|
||
func (m requestBuilderModel) Update(msg tea.Msg) (requestBuilderModel, tea.Cmd) {
|
||
if key, ok := msg.(tea.KeyMsg); ok {
|
||
switch key.String() {
|
||
case "tab":
|
||
m.focusIdx = (m.focusIdx + 1) % m.totalFocusable()
|
||
m.applyFocus()
|
||
return m, nil
|
||
case "shift+tab":
|
||
m.focusIdx--
|
||
if m.focusIdx < 0 {
|
||
m.focusIdx = m.totalFocusable() - 1
|
||
}
|
||
m.applyFocus()
|
||
return m, nil
|
||
case "ctrl+g":
|
||
// смена режима атаки; ctrl+m конфликтует с Enter в некоторых
|
||
// терминалах, поэтому используем ctrl+g ("go mode")
|
||
m.mode = nextMode(m.mode)
|
||
return m, nil
|
||
case "ctrl+t":
|
||
m.insecure = !m.insecure
|
||
return m, nil
|
||
case "ctrl+h":
|
||
if m.scheme == "https" {
|
||
m.scheme = "http"
|
||
} else {
|
||
m.scheme = "https"
|
||
}
|
||
return m, nil
|
||
case "ctrl+p":
|
||
ti := textinput.New()
|
||
ti.Placeholder = "wordlist:путь | range:1:100:1 | preset:numbers-small | ctrl+n/ctrl+l"
|
||
ti.CharLimit = 256
|
||
m.payloadInputs = append(m.payloadInputs, ti)
|
||
return m, nil
|
||
case "ctrl+x":
|
||
if len(m.payloadInputs) > 1 {
|
||
m.payloadInputs = m.payloadInputs[:len(m.payloadInputs)-1]
|
||
if m.focusIdx >= m.totalFocusable() {
|
||
m.focusIdx = m.totalFocusable() - 1
|
||
}
|
||
m.applyFocus()
|
||
}
|
||
return m, nil
|
||
case "ctrl+r":
|
||
m.errMsg = ""
|
||
m.lastSentRaw = m.textarea.Value()
|
||
return m, m.runCmd()
|
||
case "ctrl+n":
|
||
m.suggestPreset()
|
||
return m, nil
|
||
case "ctrl+l":
|
||
m.cyclePreset()
|
||
return m, nil
|
||
case "ctrl+u":
|
||
m.recursionEnabled = !m.recursionEnabled
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
var cmd tea.Cmd
|
||
switch {
|
||
case m.focusIdx == 0:
|
||
m.textarea, cmd = m.textarea.Update(msg)
|
||
case m.focusIdx <= len(m.payloadInputs):
|
||
m.payloadInputs[m.focusIdx-1], cmd = m.payloadInputs[m.focusIdx-1].Update(msg)
|
||
case m.focusIdx == len(m.payloadInputs)+1:
|
||
m.concurrency, cmd = m.concurrency.Update(msg)
|
||
case m.focusIdx == len(m.payloadInputs)+2:
|
||
m.rateLimitMs, cmd = m.rateLimitMs.Update(msg)
|
||
default:
|
||
m.timeoutSec, cmd = m.timeoutSec.Update(msg)
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
// currentPayloadPoint возвращает индекс точки вставки (0-based),
|
||
// соответствующей полю payload'а в фокусе, и true — если фокус сейчас
|
||
// действительно на одном из полей payload'ов (а не на textarea или
|
||
// concurrency/rateLimit/timeout).
|
||
func (m *requestBuilderModel) currentPayloadPoint() (int, bool) {
|
||
if m.focusIdx < 1 || m.focusIdx > len(m.payloadInputs) {
|
||
return 0, false
|
||
}
|
||
return m.focusIdx - 1, true
|
||
}
|
||
|
||
// suggestPreset (ctrl+n) определяет имя параметра рядом с §маркером§,
|
||
// на который смотрит поле payload'а в фокусе, и подставляет туда лучшую
|
||
// по эвристике заготовку из payload.SmartSuggest. Остальные варианты
|
||
// показывает в statusMsg — это подсказка, а не единственно верный ответ,
|
||
// пользователь всегда может переопределить вручную.
|
||
func (m *requestBuilderModel) suggestPreset() {
|
||
point, ok := m.currentPayloadPoint()
|
||
if !ok {
|
||
m.statusMsg = "ctrl+n работает, когда в фокусе поле payload'а (не запрос и не параметры движка)"
|
||
return
|
||
}
|
||
|
||
paramName := engine.DetectParamName(m.textarea.Value(), point)
|
||
suggestions := payload.SmartSuggest(paramName)
|
||
if len(suggestions) == 0 {
|
||
m.statusMsg = "не удалось предложить заготовку для этой точки"
|
||
return
|
||
}
|
||
|
||
m.payloadInputs[point].SetValue("preset:" + suggestions[0])
|
||
|
||
label := paramName
|
||
if label == "" {
|
||
label = "(имя параметра не распознано)"
|
||
}
|
||
if len(suggestions) > 1 {
|
||
m.statusMsg = fmt.Sprintf("Подсказка для %q: preset:%s (ещё варианты: %s)", label, suggestions[0], strings.Join(suggestions[1:], ", "))
|
||
} else {
|
||
m.statusMsg = fmt.Sprintf("Подсказка для %q: preset:%s", label, suggestions[0])
|
||
}
|
||
}
|
||
|
||
// cyclePreset (ctrl+l) обходит все встроенные заготовки по кругу,
|
||
// независимо от контекста — для случаев, когда хочется вручную
|
||
// пролистать варианты (числа, буквы и т.д.), а не полагаться на
|
||
// эвристику suggestPreset.
|
||
func (m *requestBuilderModel) cyclePreset() {
|
||
point, ok := m.currentPayloadPoint()
|
||
if !ok {
|
||
m.statusMsg = "ctrl+l работает, когда в фокусе поле payload'а (не запрос и не параметры движка)"
|
||
return
|
||
}
|
||
|
||
names := payload.PresetNames()
|
||
if len(names) == 0 {
|
||
return
|
||
}
|
||
name := names[m.presetCycleIdx%len(names)]
|
||
m.presetCycleIdx++
|
||
|
||
m.payloadInputs[point].SetValue("preset:" + name)
|
||
m.statusMsg = fmt.Sprintf("%s — %s", name, payload.PresetDescription(name))
|
||
}
|
||
|
||
// runCmd снимает копию всех значений формы (чтобы не держать ссылку на
|
||
// изменяемую модель внутри замыкания команды), валидирует их и запускает
|
||
// атаку. Возвращает attackStartedMsg с каналом результатов и cancel'ом,
|
||
// либо errMsg с человекочитаемой причиной отказа.
|
||
func (m requestBuilderModel) runCmd() tea.Cmd {
|
||
raw := m.textarea.Value()
|
||
mode := m.mode
|
||
insecure := m.insecure
|
||
scheme := m.scheme
|
||
recursionEnabled := m.recursionEnabled
|
||
|
||
specs := make([]string, len(m.payloadInputs))
|
||
for i, ti := range m.payloadInputs {
|
||
specs[i] = ti.Value()
|
||
}
|
||
concurrencyStr := m.concurrency.Value()
|
||
rateLimitStr := m.rateLimitMs.Value()
|
||
timeoutStr := m.timeoutSec.Value()
|
||
|
||
return func() tea.Msg {
|
||
clean, points, err := engine.ParseMarkers(raw)
|
||
if err != nil {
|
||
return errMsg{fmt.Errorf("маркеры §...§: %w", err)}
|
||
}
|
||
if len(points) == 0 {
|
||
return errMsg{fmt.Errorf("в запросе нет ни одного §маркера§ — нечего фаззить")}
|
||
}
|
||
|
||
gens := make([]payload.Generator, 0, len(specs))
|
||
for _, spec := range specs {
|
||
if spec == "" {
|
||
continue
|
||
}
|
||
g, err := cli.ParsePayloadSpec(spec)
|
||
if err != nil {
|
||
return errMsg{fmt.Errorf("payload %q: %w", spec, err)}
|
||
}
|
||
gens = append(gens, g)
|
||
}
|
||
if len(gens) == 0 {
|
||
return errMsg{fmt.Errorf("укажи хотя бы один payload (ctrl+p — добавить поле)")}
|
||
}
|
||
|
||
concurrency, _ := strconv.Atoi(concurrencyStr)
|
||
if concurrency <= 0 {
|
||
concurrency = 10
|
||
}
|
||
rateLimitVal, _ := strconv.Atoi(rateLimitStr)
|
||
timeoutVal, _ := strconv.Atoi(timeoutStr)
|
||
if timeoutVal <= 0 {
|
||
timeoutVal = 10
|
||
}
|
||
rateLimit := time.Duration(rateLimitVal) * time.Millisecond
|
||
timeout := time.Duration(timeoutVal) * time.Second
|
||
|
||
eng := engine.New(engine.Config{
|
||
Concurrency: concurrency,
|
||
RateLimit: rateLimit,
|
||
Timeout: timeout,
|
||
InsecureSkipVerify: insecure,
|
||
ForceScheme: scheme,
|
||
})
|
||
|
||
base := make([]string, len(points))
|
||
total := engine.EstimateTotal(mode, points, gens)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
ch, err := eng.Run(ctx, mode, clean, points, gens, base)
|
||
if err != nil {
|
||
cancel()
|
||
return errMsg{err}
|
||
}
|
||
|
||
var recursion *recursionMeta
|
||
if recursionEnabled && len(points) == 1 {
|
||
statusPred, _ := engine.ParseStatusFilter(defaultRecursionStatuses) // синтаксис уже проверен константой, ошибки не будет
|
||
recursion = &recursionMeta{
|
||
rawTemplate: raw,
|
||
pointIndex: 0,
|
||
mode: mode,
|
||
specs: specs,
|
||
depth: 0,
|
||
maxDepth: defaultRecursionMaxDepth,
|
||
statusPred: statusPred,
|
||
concurrency: concurrency,
|
||
rateLimit: rateLimit,
|
||
timeout: timeout,
|
||
insecure: insecure,
|
||
forceScheme: scheme,
|
||
}
|
||
}
|
||
|
||
return attackStartedMsg{ch: ch, cancel: cancel, total: total, recursion: recursion}
|
||
}
|
||
}
|
||
|
||
func (m requestBuilderModel) View() string {
|
||
var b strings.Builder
|
||
|
||
b.WriteString(fieldLabel("Запрос (§...§ отмечает точку вставки)", m.focusIdx == 0))
|
||
b.WriteString(m.textarea.View())
|
||
b.WriteString("\n\n")
|
||
|
||
b.WriteString(fmt.Sprintf("Режим: %-11s (ctrl+g) Схема: %-5s (ctrl+h) Insecure TLS: %v (ctrl+t)\n", modeName(m.mode), m.scheme, m.insecure))
|
||
b.WriteString(fmt.Sprintf("Рекурсия в директории: %v (ctrl+u; работает только с одним §маркером§, статусы %s, глубина %d)\n\n", m.recursionEnabled, defaultRecursionStatuses, defaultRecursionMaxDepth))
|
||
|
||
b.WriteString("Payload'ы, по одному на §маркер§ (ctrl+p — добавить, ctrl+x — убрать):\n")
|
||
for i, ti := range m.payloadInputs {
|
||
b.WriteString(fieldLabel(fmt.Sprintf(" #%d", i+1), m.focusIdx == i+1))
|
||
b.WriteString(ti.View())
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("\n")
|
||
|
||
idxConcurrency := len(m.payloadInputs) + 1
|
||
idxRateLimit := len(m.payloadInputs) + 2
|
||
idxTimeout := len(m.payloadInputs) + 3
|
||
|
||
b.WriteString(fieldLabel("Concurrency", m.focusIdx == idxConcurrency))
|
||
b.WriteString(m.concurrency.View())
|
||
b.WriteString("\n")
|
||
b.WriteString(fieldLabel("Rate limit, мс", m.focusIdx == idxRateLimit))
|
||
b.WriteString(m.rateLimitMs.View())
|
||
b.WriteString("\n")
|
||
b.WriteString(fieldLabel("Timeout, с", m.focusIdx == idxTimeout))
|
||
b.WriteString(m.timeoutSec.View())
|
||
b.WriteString("\n\n")
|
||
|
||
if m.errMsg != "" {
|
||
b.WriteString("Ошибка: " + m.errMsg + "\n\n")
|
||
}
|
||
if m.statusMsg != "" {
|
||
b.WriteString(m.statusMsg + "\n\n")
|
||
}
|
||
|
||
b.WriteString("tab/shift+tab — между полями · ctrl+r — атака · ctrl+n — подсказать заготовку\n")
|
||
b.WriteString("ctrl+l — обзор заготовок · ctrl+u — рекурсия · f2 — результаты · ctrl+c — выход\n")
|
||
return b.String()
|
||
}
|
||
|
||
// fieldLabel помечает подпись поля маркером «▸», если оно сейчас в фокусе.
|
||
func fieldLabel(name string, focused bool) string {
|
||
marker := " "
|
||
if focused {
|
||
marker = "▸ "
|
||
}
|
||
return marker + name + "\n"
|
||
}
|