1413 lines
53 KiB
Go
1413 lines
53 KiB
Go
// Package tui реализует интерактивный интерфейс burterm на bubbletea.
|
||
//
|
||
// Собрано под bubbles v0.20.0 / bubbletea v1.2.4 (см. go.mod). Собрать и
|
||
// проверить компиляцию в этом окружении не удалось (нет сети до
|
||
// proxy.golang.org и не установлен сам Go) — при первой сборке возможны
|
||
// мелкие несовпадения сигнатур (напр. если Focus() в твоей версии bubbles
|
||
// не возвращает tea.Cmd) — правится точечно по ошибкам компилятора.
|
||
package tui
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/url"
|
||
"runtime"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/charmbracelet/bubbles/spinner"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"github.com/r3g1tpr0cs/burterm/internal/cli"
|
||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||
"github.com/r3g1tpr0cs/burterm/internal/fuzzer"
|
||
"github.com/r3g1tpr0cs/burterm/internal/payload"
|
||
"github.com/r3g1tpr0cs/burterm/internal/project"
|
||
"github.com/r3g1tpr0cs/burterm/internal/proxy"
|
||
"github.com/r3g1tpr0cs/burterm/internal/scanner"
|
||
"github.com/r3g1tpr0cs/burterm/internal/sitemap"
|
||
"github.com/r3g1tpr0cs/burterm/internal/spider"
|
||
)
|
||
|
||
type view int
|
||
|
||
const (
|
||
viewRequestBuilder view = iota
|
||
viewResults
|
||
viewProxy
|
||
viewRepeater
|
||
viewSpider
|
||
viewTarget
|
||
viewDecoder
|
||
viewComparer
|
||
viewSequencer
|
||
viewDashboard
|
||
viewScanner
|
||
viewProject
|
||
viewFuzzer
|
||
)
|
||
|
||
// resultMsg — одна строка результата, помеченная ID атаки, которой она
|
||
// принадлежит. Это критично, когда одновременно идёт несколько атак:
|
||
// без ID результаты разных прогонов было бы невозможно развести
|
||
// по своим resultsModel.
|
||
type resultMsg struct {
|
||
attackID int
|
||
result engine.Result
|
||
}
|
||
|
||
// resultsDoneMsg — канал результатов конкретной атаки закрылся.
|
||
type resultsDoneMsg struct{ attackID int }
|
||
|
||
// attackStartedMsg несёт канал результатов, функцию отмены и ожидаемое
|
||
// общее число запросов (для индикации прогресса N/Total).
|
||
type attackStartedMsg struct {
|
||
ch <-chan engine.Result
|
||
cancel context.CancelFunc
|
||
total int
|
||
recursion *recursionMeta // nil — рекурсия выключена для этой атаки
|
||
}
|
||
|
||
// errMsg — ошибка валидации формы или запуска атаки на вкладке Request.
|
||
type errMsg struct{ err error }
|
||
|
||
// proxyEntryMsg — новая запись, перехваченная прокси-сервером.
|
||
type proxyEntryMsg proxy.Entry
|
||
|
||
// loadTarget указывает, куда именно отправить перехваченный запрос
|
||
// из вкладки Proxy: в форму Intruder-атаки или в Repeater.
|
||
type loadTarget int
|
||
|
||
const (
|
||
targetBuilder loadTarget = iota
|
||
targetRepeater
|
||
)
|
||
|
||
// loadEntryMsg — команда "загрузить этот перехваченный запрос", возникает
|
||
// на вкладке Proxy по enter (targetBuilder) или s (targetRepeater).
|
||
type loadEntryMsg struct {
|
||
entry proxy.Entry
|
||
target loadTarget
|
||
}
|
||
|
||
// repeaterRespMsg — ответ, полученный Repeater'ом на ручную отправку запроса.
|
||
type repeaterRespMsg struct {
|
||
response string
|
||
status string
|
||
statusCode int
|
||
duration time.Duration
|
||
}
|
||
|
||
// repeaterErrMsg — отдельный тип ошибки для Repeater, чтобы не переключать
|
||
// вкладку на Request при ошибке отправки из Repeater.
|
||
type repeaterErrMsg struct{ err error }
|
||
|
||
// spiderStartedMsg несёт канал найденных страниц и функцию отмены обхода.
|
||
type spiderStartedMsg struct {
|
||
ch <-chan spider.Page
|
||
cancel context.CancelFunc
|
||
sessionDetected bool // сессионные Cookie/Authorization подхвачены из истории Proxy
|
||
}
|
||
|
||
// spiderPageMsg — одна найденная страница, полученная из канала spider.Page.
|
||
type spiderPageMsg spider.Page
|
||
|
||
// spiderDoneMsg сигнализирует, что канал страниц закрыт — обход завершён.
|
||
type spiderDoneMsg struct{}
|
||
|
||
// spiderErrMsg — ошибка валидации формы или запуска краулинга.
|
||
type spiderErrMsg struct{ err error }
|
||
|
||
// dashboardTickMsg — сигнал раз в секунду для обновления метрик на
|
||
// вкладке Dashboard. В отличие от остальных вкладок, здесь нет события,
|
||
// по приходу которого можно было бы обновиться, — метрики процесса
|
||
// (горутины, память) нужно перечитывать даже тогда, когда никаких новых
|
||
// сообщений не приходит. Второе (и пока последнее) место в приложении,
|
||
// где используется tea.Tick — см. resultsTickMsg ниже.
|
||
type dashboardTickMsg time.Time
|
||
|
||
// resultsTickMsg — сигнал раз в 300мс на рендер видимой сейчас атаки
|
||
// в Results (см. doc у resultsTickCmd и у resultsModel).
|
||
type resultsTickMsg time.Time
|
||
|
||
// scanStartedMsg несёт канал событий сканирования и функцию отмены.
|
||
type scanStartedMsg struct {
|
||
ch <-chan scanner.Event
|
||
cancel context.CancelFunc
|
||
}
|
||
|
||
// scanFindingMsg — одна находка, извлечённая из scanner.Event{Kind: EventFinding}.
|
||
type scanFindingMsg scanner.Finding
|
||
|
||
// scanProgressMsg — тик прогресса, извлечённый из scanner.Event{Kind: EventProgress}.
|
||
type scanProgressMsg struct{ done, total int }
|
||
|
||
// scanDoneMsg сигнализирует, что канал событий закрылся — сканирование завершено.
|
||
type scanDoneMsg struct{}
|
||
|
||
// scanErrMsg — ошибка валидации формы или запуска сканирования.
|
||
type scanErrMsg struct{ err error }
|
||
|
||
// corsResultMsg — результат одноразовой CORS-проверки (ctrl+o на вкладке
|
||
// Scanner). finding может быть nil — это означает "проверка прошла, но
|
||
// небезопасной конфигурации не найдено", а не ошибку.
|
||
type corsResultMsg struct{ finding *scanner.Finding }
|
||
|
||
// projectOpenedMsg — файл проекта успешно открыт/создан.
|
||
type projectOpenedMsg struct {
|
||
store *project.Store
|
||
path string
|
||
}
|
||
|
||
// projectErrMsg — ошибка любой операции с проектом (открытие, поиск,
|
||
// смена метки, удаление, автосохранение из Proxy).
|
||
type projectErrMsg struct{ err error }
|
||
|
||
// projectSearchMsg — результат поиска по проекту.
|
||
type projectSearchMsg struct{ results []project.Record }
|
||
|
||
// projectColorSetMsg — цветовая метка записи обновлена в БД, нужно
|
||
// отразить это в уже загруженном списке результатов без перезапроса.
|
||
type projectColorSetMsg struct {
|
||
id int64
|
||
color project.ColorLabel
|
||
}
|
||
|
||
// projectDeletedMsg — запись удалена из БД и должна пропасть из списка.
|
||
type projectDeletedMsg struct{ id int64 }
|
||
|
||
// projectLoadMsg — команда "загрузить эту сохранённую запись" с вкладки
|
||
// Project, аналог loadEntryMsg для Proxy.
|
||
type projectLoadMsg struct {
|
||
record project.Record
|
||
target loadTarget
|
||
}
|
||
|
||
// saveToProjectMsg — команда "сохрани эти записи в открытый проект",
|
||
// возникает по ctrl+p на Repeater/Results/Scanner. origin — откуда
|
||
// пришла команда, чтобы результат (успех/ошибка) можно было отразить
|
||
// именно в той вкладке, где нажали ctrl+p, а не только на вкладке Project.
|
||
type saveToProjectMsg struct {
|
||
origin view
|
||
records []project.Record
|
||
}
|
||
|
||
// projectSaveResultMsg — итог сохранения по saveToProjectMsg.
|
||
type projectSaveResultMsg struct {
|
||
origin view
|
||
saved, total int
|
||
err error
|
||
}
|
||
|
||
// saveScopeMsg — команда "сохрани текущие scope-правила Target в проект"
|
||
// (ctrl+p на вкладке Target). json уже сериализован в targetModel —
|
||
// корневая модель только пишет готовую строку в meta.
|
||
type saveScopeMsg struct{ json string }
|
||
|
||
// projectMetaSavedMsg — итог сохранения scope-правил.
|
||
type projectMetaSavedMsg struct{ err error }
|
||
|
||
// scopeChangedMsg — правила scope на Target изменились (добавили/удалили
|
||
// правило) и нужно передать актуальный снимок в живой proxy.Server, чтобы
|
||
// скип трафика вне scope работал по свежим правилам, а не по тем, что
|
||
// были на момент запуска TUI.
|
||
type scopeChangedMsg struct{ rules []sitemap.Rule }
|
||
|
||
// fuzzStartedMsg несёт канал событий фаззера, функцию отмены и путь
|
||
// к директории, куда сохраняются краши.
|
||
type fuzzStartedMsg struct {
|
||
ch <-chan fuzzer.Event
|
||
cancel context.CancelFunc
|
||
crashDir string
|
||
}
|
||
|
||
// fuzzCrashMsg — одна находка, извлечённая из fuzzer.Event{Kind: EventCrash}.
|
||
type fuzzCrashMsg fuzzer.Crash
|
||
|
||
// fuzzProgressMsg — тик прогресса, извлечённый из fuzzer.Event{Kind: EventProgress}.
|
||
type fuzzProgressMsg struct{ execs int64 }
|
||
|
||
// fuzzDoneMsg сигнализирует, что канал событий закрылся — фаззинг остановлен.
|
||
type fuzzDoneMsg struct{}
|
||
|
||
// fuzzErrMsg — ошибка валидации формы или запуска фаззера.
|
||
type fuzzErrMsg struct{ err error }
|
||
|
||
// attackRun — одна запущенная Intruder-атака: канал результатов, функция
|
||
// отмены именно этой атаки (не всех разом) и её собственная вкладка
|
||
// результатов. Несколько attackRun могут существовать и получать
|
||
// результаты одновременно — так и обеспечивается параллельность атак
|
||
// на уровне TUI, а не только на уровне воркеров внутри одной атаки.
|
||
type attackRun struct {
|
||
id int
|
||
ch <-chan engine.Result
|
||
cancel context.CancelFunc
|
||
results resultsModel
|
||
running bool
|
||
startedAt time.Time
|
||
total int // ожидаемое общее число запросов, 0 — неизвестно/не показывать %
|
||
|
||
recursion *recursionMeta // nil — рекурсия выключена для этой атаки
|
||
}
|
||
|
||
// recursionMeta — всё, что нужно, чтобы после завершения атаки
|
||
// автоматически запустить новые под-атаки внутри найденных "директорий"
|
||
// (аналог -recursion у ffuf). Хранит СПЕЦИФИКАЦИИ payload'ов (строки
|
||
// вида "wordlist:common.txt"), а не уже готовые payload.Generator —
|
||
// генераторы одноразовые (Next/Reset потребляют состояние), для каждой
|
||
// под-атаки нужны свежие экземпляры, поэтому пересобираются из тех же
|
||
// specs через cli.ParsePayloadSpec заново при каждом рекурсивном вызове.
|
||
type recursionMeta struct {
|
||
rawTemplate string // исходный сырой текст запроса, всё ещё с §маркером§
|
||
pointIndex int // индекс точки вставки, по которой идёт рекурсия (сейчас всегда 0 — см. requestBuilderModel.runCmd)
|
||
mode engine.AttackMode
|
||
specs []string
|
||
depth int
|
||
maxDepth int
|
||
statusPred engine.StatusFilter // какие статусы считаются "нашли директорию, стоит углубиться"
|
||
|
||
concurrency int
|
||
rateLimit time.Duration
|
||
timeout time.Duration
|
||
insecure bool
|
||
forceScheme string
|
||
}
|
||
|
||
// Model — корневая модель приложения: тринадцать вкладок (форма запроса,
|
||
// результаты — с возможностью переключаться между несколькими
|
||
// одновременно идущими атаками, перехваченные прокси-запросы, Repeater,
|
||
// Spider, Target/Site Map, Decoder, Comparer, Sequencer, Dashboard,
|
||
// Scanner, Project, Fuzzer).
|
||
type Model struct {
|
||
active view
|
||
width int
|
||
height int
|
||
|
||
builder requestBuilderModel
|
||
proxyView proxyModel
|
||
repeaterView repeaterModel
|
||
spiderView spiderModel
|
||
targetView targetModel
|
||
decoderView decoderModel
|
||
comparerView comparerModel
|
||
sequencerVw sequencerModel
|
||
dashboardVw dashboardModel
|
||
scannerVw scannerModel
|
||
projectView projectModel
|
||
fuzzerVw fuzzerModel
|
||
|
||
attacks []*attackRun
|
||
activeAttack int
|
||
nextAttackID int
|
||
|
||
spiderCh <-chan spider.Page
|
||
spiderCancel context.CancelFunc
|
||
|
||
scanCh <-chan scanner.Event
|
||
scanCancel context.CancelFunc
|
||
|
||
fuzzCh <-chan fuzzer.Event
|
||
fuzzCancel context.CancelFunc
|
||
|
||
projectStore *project.Store // nil, пока проект не открыт; закрывается при выходе (ctrl+c) или при открытии другого
|
||
|
||
proxyCh <-chan proxy.Entry
|
||
proxyAddr string
|
||
caPath string
|
||
proxyServer *proxy.Server // для прокидывания scope-фильтра (SetScope) при изменении правил на Target
|
||
|
||
programStart time.Time
|
||
}
|
||
|
||
// New создаёт начальное состояние TUI. proxyStore может быть nil (если
|
||
// прокси не удалось поднять) — тогда вкладка Proxy просто не будет
|
||
// получать новые записи. proxyServer — сам работающий Server (для
|
||
// прокидывания scope-фильтра при изменении правил на Target), тоже может
|
||
// быть nil в паре с proxyStore. projectStore может быть nil (проект ещё
|
||
// не открывался, либо автозагрузка последнего проекта не удалась) —
|
||
// тогда вкладка Project стартует пустой, как и раньше, ctrl+o по-прежнему
|
||
// открывает/создаёт файл вручную.
|
||
func New(proxyStore *proxy.Store, proxyAddr, caPath string, proxyServer *proxy.Server, projectStore *project.Store) Model {
|
||
pv := newProxyView()
|
||
tv := newTargetView()
|
||
|
||
var ch <-chan proxy.Entry
|
||
if proxyStore != nil {
|
||
for _, e := range proxyStore.All() {
|
||
pv.AddEntry(e)
|
||
tv.AddEntry(e)
|
||
}
|
||
ch = proxyStore.Subscribe()
|
||
}
|
||
|
||
pjv := newProjectView()
|
||
if projectStore != nil {
|
||
pjv.store = projectStore
|
||
pjv.path = projectStore.Path
|
||
pjv.pathInput.SetValue(projectStore.Path)
|
||
applyProjectScope(&tv, projectStore)
|
||
}
|
||
pushScopeToProxy(proxyServer, tv.scope)
|
||
|
||
m := Model{
|
||
active: viewDashboard,
|
||
builder: newRequestBuilder(),
|
||
proxyView: pv,
|
||
repeaterView: newRepeater(),
|
||
spiderView: newSpiderView(),
|
||
targetView: tv,
|
||
decoderView: newDecoderView(),
|
||
comparerView: newComparerView(),
|
||
sequencerVw: newSequencerView(),
|
||
dashboardVw: newDashboardView(),
|
||
scannerVw: newScannerView(),
|
||
projectView: pjv,
|
||
fuzzerVw: newFuzzerView(),
|
||
projectStore: projectStore,
|
||
proxyCh: ch,
|
||
proxyAddr: proxyAddr,
|
||
caPath: caPath,
|
||
proxyServer: proxyServer,
|
||
programStart: time.Now(),
|
||
}
|
||
|
||
// Dashboard теперь стартовая вкладка — без этого пользователь первую
|
||
// секунду видел бы пустую панель: содержимое обычно заполняется
|
||
// тиком dashboardTickCmd раз в секунду, а раньше это было незаметно,
|
||
// потому что на Dashboard физически не успевали переключиться так
|
||
// быстро. Один синхронный рендер здесь дешёвый (то же самое, что и
|
||
// renderDashboard() внутри тика) и не блокирует ничего сетевого.
|
||
m.dashboardVw.SetContent(m.renderDashboard())
|
||
|
||
return m
|
||
}
|
||
|
||
func (m Model) Init() tea.Cmd {
|
||
cmds := []tea.Cmd{dashboardTickCmd(), resultsTickCmd()}
|
||
if m.proxyCh != nil {
|
||
cmds = append(cmds, waitForProxyEntry(m.proxyCh))
|
||
}
|
||
return tea.Batch(cmds...)
|
||
}
|
||
|
||
// dashboardTickCmd планирует следующее обновление метрик через секунду.
|
||
// Вызывается один раз в Init и затем сам себя переиздаёт в обработчике
|
||
// dashboardTickMsg — так тикер живёт всё время работы TUI, не завися
|
||
// от того, какая вкладка сейчас активна.
|
||
func dashboardTickCmd() tea.Cmd {
|
||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||
return dashboardTickMsg(t)
|
||
})
|
||
}
|
||
|
||
// resultsTickCmd планирует следующий рендер результатов через 300мс —
|
||
// тот же принцип, что и dashboardTickCmd, но чаще (результаты атаки
|
||
// хочется видеть live, не раз в секунду) и с более узкой целью: тик
|
||
// коммитит (Commit) только ту атаку, что сейчас видна на вкладке
|
||
// Results — остальные копят content в фоне без дорогого
|
||
// viewport.SetContent, пока пользователь на них не переключится
|
||
// (см. doc resultsModel в results.go).
|
||
func resultsTickCmd() tea.Cmd {
|
||
return tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
|
||
return resultsTickMsg(t)
|
||
})
|
||
}
|
||
|
||
// findAttack ищет запущенную атаку по ID. attacks — срез указателей,
|
||
// поэтому найденный *attackRun можно мутировать напрямую даже притом,
|
||
// что Model передаётся по значению — сам срез копируется, но элементы
|
||
// в нём общие.
|
||
func (m Model) findAttack(id int) *attackRun {
|
||
for _, a := range m.attacks {
|
||
if a.id == id {
|
||
return a
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width, m.height = msg.Width, msg.Height
|
||
m.builder.textarea.SetWidth(msg.Width - 2)
|
||
m.builder.textarea.SetHeight(msg.Height / 3)
|
||
|
||
// Бюджеты высоты ниже учитывают только "хром" таб-бара: он стал
|
||
// двухрядным вместо однорядного, +1 строка к тому, что было
|
||
// исходно (-6/-8). Раньше здесь была ещё поправка на panel(),
|
||
// в которую заворачивалось тело каждой вкладки — откатил её
|
||
// (см. комментарий в Model.View): дважды подряд не смог вслепую
|
||
// подобрать для неё правильный бюджет высоты, то меню обрезалось,
|
||
// то сама рамка вылезала за край экрана. Теперь чрома меньше и
|
||
// компенсация проще — есть шанс, что +1 наконец точное число,
|
||
// но и это не проверено на реальном терминале.
|
||
for _, a := range m.attacks {
|
||
a.results.SetSize(msg.Width-2, msg.Height-6)
|
||
}
|
||
m.proxyView.SetSize(msg.Width-2, msg.Height-5)
|
||
m.repeaterView.SetSize(msg.Width-2, msg.Height-9)
|
||
m.spiderView.SetSize(msg.Width-2, msg.Height-7)
|
||
m.targetView.SetSize(msg.Width-2, msg.Height-7)
|
||
m.decoderView.SetSize(msg.Width-2, msg.Height-7)
|
||
m.comparerView.SetSize(msg.Width-2, msg.Height-3)
|
||
m.sequencerVw.SetSize(msg.Width-2, msg.Height-7)
|
||
m.dashboardVw.SetSize(msg.Width-2, msg.Height-7)
|
||
m.scannerVw.SetSize(msg.Width-2, msg.Height-7)
|
||
m.projectView.SetSize(msg.Width-2, msg.Height-7)
|
||
m.fuzzerVw.SetSize(msg.Width-2, msg.Height-7)
|
||
return m, nil
|
||
|
||
case tea.KeyMsg:
|
||
switch msg.String() {
|
||
case "ctrl+c":
|
||
// Выход останавливает все текущие атаки и краулинг, а не только
|
||
// видимую вкладку — иначе фоновые остались бы висеть до
|
||
// истечения таймаутов.
|
||
for _, a := range m.attacks {
|
||
if a.cancel != nil {
|
||
a.cancel()
|
||
}
|
||
}
|
||
if m.spiderCancel != nil {
|
||
m.spiderCancel()
|
||
}
|
||
if m.scanCancel != nil {
|
||
m.scanCancel()
|
||
}
|
||
if m.fuzzCancel != nil {
|
||
m.fuzzCancel()
|
||
}
|
||
if m.projectStore != nil {
|
||
m.projectStore.Close()
|
||
}
|
||
return m, tea.Quit
|
||
case "f1":
|
||
m.active = viewDashboard
|
||
return m, nil
|
||
case "f2":
|
||
m.active = viewRequestBuilder
|
||
return m, nil
|
||
case "f3":
|
||
m.active = viewResults
|
||
if a := m.currentAttack(); a != nil {
|
||
a.results.Commit()
|
||
}
|
||
return m, nil
|
||
case "f4":
|
||
m.active = viewProxy
|
||
return m, nil
|
||
case "f5":
|
||
m.active = viewRepeater
|
||
return m, nil
|
||
case "f6":
|
||
m.active = viewSpider
|
||
return m, nil
|
||
case "f7":
|
||
m.active = viewTarget
|
||
return m, nil
|
||
case "f8":
|
||
m.active = viewDecoder
|
||
return m, nil
|
||
case "f9":
|
||
m.active = viewComparer
|
||
return m, nil
|
||
case "f10":
|
||
m.active = viewSequencer
|
||
return m, nil
|
||
case "f11":
|
||
m.active = viewScanner
|
||
return m, nil
|
||
case "f12":
|
||
m.active = viewProject
|
||
return m, nil
|
||
case "pgup":
|
||
m.active = viewFuzzer
|
||
return m, nil
|
||
case "esc":
|
||
// esc отменяет только то, что видно на экране прямо сейчас —
|
||
// остальное (другие атаки, краулинг) продолжает идти в фоне.
|
||
if m.active == viewResults {
|
||
if a := m.currentAttack(); a != nil && a.running && a.cancel != nil {
|
||
a.cancel()
|
||
}
|
||
}
|
||
if m.active == viewSpider && m.spiderView.running && m.spiderCancel != nil {
|
||
m.spiderCancel()
|
||
}
|
||
if m.active == viewScanner && m.scannerVw.running && m.scanCancel != nil {
|
||
m.scanCancel()
|
||
}
|
||
if m.active == viewFuzzer && m.fuzzerVw.running && m.fuzzCancel != nil {
|
||
m.fuzzCancel()
|
||
}
|
||
return m, nil
|
||
case "[":
|
||
if m.active == viewResults && m.activeAttack > 0 {
|
||
m.activeAttack--
|
||
if a := m.currentAttack(); a != nil {
|
||
a.results.Commit()
|
||
}
|
||
}
|
||
return m, nil
|
||
case "]":
|
||
if m.active == viewResults && m.activeAttack < len(m.attacks)-1 {
|
||
m.activeAttack++
|
||
if a := m.currentAttack(); a != nil {
|
||
a.results.Commit()
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
case attackStartedMsg:
|
||
id := m.nextAttackID
|
||
m.nextAttackID++
|
||
|
||
run := &attackRun{id: id, ch: msg.ch, cancel: msg.cancel, results: newResults(), running: true, startedAt: time.Now(), total: msg.total, recursion: msg.recursion}
|
||
run.results.Reset()
|
||
run.results.SetSize(m.width-2, m.height-6)
|
||
|
||
m.attacks = append(m.attacks, run)
|
||
m.activeAttack = len(m.attacks) - 1
|
||
m.active = viewResults
|
||
return m, waitForResult(id, run.ch)
|
||
|
||
case resultMsg:
|
||
if a := m.findAttack(msg.attackID); a != nil {
|
||
a.results.AddResult(msg.result)
|
||
return m, waitForResult(msg.attackID, a.ch)
|
||
}
|
||
return m, nil
|
||
|
||
case resultsDoneMsg:
|
||
if a := m.findAttack(msg.attackID); a != nil {
|
||
a.running = false
|
||
a.results.Finish()
|
||
if a.recursion != nil && a.recursion.depth < a.recursion.maxDepth {
|
||
if cmd := spawnRecursiveAttacks(*a.recursion, a.results.all); cmd != nil {
|
||
return m, cmd
|
||
}
|
||
}
|
||
}
|
||
return m, nil
|
||
|
||
case errMsg:
|
||
m.builder.errMsg = msg.err.Error()
|
||
m.active = viewRequestBuilder
|
||
return m, nil
|
||
|
||
case proxyEntryMsg:
|
||
entry := proxy.Entry(msg)
|
||
m.proxyView.AddEntry(entry)
|
||
m.targetView.AddEntry(entry)
|
||
m.spiderView.AddProxyEntry(entry)
|
||
if m.projectStore != nil {
|
||
return m, tea.Batch(waitForProxyEntry(m.proxyCh), projectAutoSaveCmd(m.projectStore, entry))
|
||
}
|
||
return m, waitForProxyEntry(m.proxyCh)
|
||
|
||
case loadEntryMsg:
|
||
switch msg.target {
|
||
case targetRepeater:
|
||
m.repeaterView.request.SetValue(msg.entry.Raw)
|
||
m.repeaterView.focus = 0
|
||
m.repeaterView.request.Focus()
|
||
m.active = viewRepeater
|
||
default:
|
||
m.builder.textarea.SetValue(msg.entry.Raw)
|
||
m.builder.focusIdx = 0
|
||
m.builder.applyFocus()
|
||
m.active = viewRequestBuilder
|
||
}
|
||
return m, nil
|
||
|
||
case repeaterRespMsg:
|
||
m.repeaterView.sending = false
|
||
m.repeaterView.sentCount++
|
||
m.repeaterView.lastStatus = msg.status
|
||
m.repeaterView.lastDuration = msg.duration
|
||
m.repeaterView.lastResponseRaw = msg.response
|
||
m.repeaterView.lastStatusCode = msg.statusCode
|
||
m.repeaterView.response.SetContent(msg.response)
|
||
m.repeaterView.response.GotoTop()
|
||
return m, nil
|
||
|
||
case repeaterErrMsg:
|
||
m.repeaterView.sending = false
|
||
m.repeaterView.errMsg = msg.err.Error()
|
||
return m, nil
|
||
|
||
case spiderStartedMsg:
|
||
m.spiderView.running = true
|
||
m.spiderView.pages = nil
|
||
m.spiderView.count = 0
|
||
m.spiderView.errMsg = ""
|
||
m.spiderView.sessionDetected = msg.sessionDetected
|
||
m.spiderCh = msg.ch
|
||
m.spiderCancel = msg.cancel
|
||
m.active = viewSpider
|
||
return m, tea.Batch(waitForSpiderPage(m.spiderCh), m.spiderView.spinner.Tick)
|
||
|
||
case spiderPageMsg:
|
||
m.spiderView.AddPage(spider.Page(msg))
|
||
return m, waitForSpiderPage(m.spiderCh)
|
||
|
||
case spiderDoneMsg:
|
||
m.spiderView.Finish()
|
||
return m, nil
|
||
|
||
case spiderErrMsg:
|
||
m.spiderView.running = false
|
||
m.spiderView.errMsg = msg.err.Error()
|
||
return m, nil
|
||
|
||
case scanStartedMsg:
|
||
m.scannerVw.Reset()
|
||
m.scanCh = msg.ch
|
||
m.scanCancel = msg.cancel
|
||
return m, waitForScanEvent(m.scanCh)
|
||
|
||
case scanFindingMsg:
|
||
m.scannerVw.AddFinding(scanner.Finding(msg))
|
||
return m, waitForScanEvent(m.scanCh)
|
||
|
||
case scanProgressMsg:
|
||
m.scannerVw.progressDone = msg.done
|
||
m.scannerVw.progressTotal = msg.total
|
||
return m, waitForScanEvent(m.scanCh)
|
||
|
||
case scanDoneMsg:
|
||
m.scannerVw.Finish()
|
||
return m, nil
|
||
|
||
case scanErrMsg:
|
||
m.scannerVw.running = false
|
||
m.scannerVw.errMsg = msg.err.Error()
|
||
return m, nil
|
||
|
||
case fuzzStartedMsg:
|
||
m.fuzzerVw.Reset()
|
||
m.fuzzerVw.crashDir = msg.crashDir
|
||
m.fuzzCh = msg.ch
|
||
m.fuzzCancel = msg.cancel
|
||
return m, waitForFuzzEvent(m.fuzzCh)
|
||
|
||
case fuzzCrashMsg:
|
||
m.fuzzerVw.AddCrash(fuzzer.Crash(msg))
|
||
return m, waitForFuzzEvent(m.fuzzCh)
|
||
|
||
case fuzzProgressMsg:
|
||
m.fuzzerVw.execs = msg.execs
|
||
return m, waitForFuzzEvent(m.fuzzCh)
|
||
|
||
case fuzzDoneMsg:
|
||
m.fuzzerVw.Finish()
|
||
return m, nil
|
||
|
||
case fuzzErrMsg:
|
||
m.fuzzerVw.running = false
|
||
m.fuzzerVw.errMsg = msg.err.Error()
|
||
return m, nil
|
||
|
||
case corsResultMsg:
|
||
if msg.finding != nil {
|
||
m.scannerVw.AddFinding(*msg.finding)
|
||
} else {
|
||
m.scannerVw.statusMsg = "CORS: небезопасной конфигурации не найдено"
|
||
}
|
||
return m, nil
|
||
|
||
case projectOpenedMsg:
|
||
if m.projectStore != nil {
|
||
m.projectStore.Close()
|
||
}
|
||
m.projectStore = msg.store
|
||
m.projectView.store = msg.store
|
||
m.projectView.path = msg.path
|
||
m.projectView.errMsg = ""
|
||
m.projectView.statusMsg = "проект открыт: " + msg.path
|
||
|
||
// Подхватываем scope-правила, если этот проект их уже сохранял
|
||
// (ctrl+p на Target), и сразу прокидываем их в живой прокси.
|
||
applyProjectScope(&m.targetView, msg.store)
|
||
pushScopeToProxy(m.proxyServer, m.targetView.scope)
|
||
return m, nil
|
||
|
||
case scopeChangedMsg:
|
||
snapshot := sitemap.NewScope()
|
||
for _, r := range msg.rules {
|
||
_ = snapshot.Add(r.Pattern, r.Exclude)
|
||
}
|
||
if m.proxyServer != nil {
|
||
m.proxyServer.SetScope(snapshot)
|
||
}
|
||
return m, nil
|
||
|
||
case projectErrMsg:
|
||
m.projectView.errMsg = msg.err.Error()
|
||
return m, nil
|
||
|
||
case projectSearchMsg:
|
||
m.projectView.results = msg.results
|
||
m.projectView.cursor = 0
|
||
m.projectView.statusMsg = fmt.Sprintf("найдено: %d", len(msg.results))
|
||
m.projectView.refreshList()
|
||
return m, nil
|
||
|
||
case projectColorSetMsg:
|
||
for i := range m.projectView.results {
|
||
if m.projectView.results[i].ID == msg.id {
|
||
m.projectView.results[i].ColorLabel = msg.color
|
||
break
|
||
}
|
||
}
|
||
m.projectView.refreshList()
|
||
return m, nil
|
||
|
||
case projectDeletedMsg:
|
||
for i, r := range m.projectView.results {
|
||
if r.ID == msg.id {
|
||
m.projectView.results = append(m.projectView.results[:i], m.projectView.results[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
if m.projectView.cursor >= len(m.projectView.results) {
|
||
m.projectView.cursor = len(m.projectView.results) - 1
|
||
}
|
||
m.projectView.refreshList()
|
||
return m, nil
|
||
|
||
case projectLoadMsg:
|
||
switch msg.target {
|
||
case targetRepeater:
|
||
m.repeaterView.request.SetValue(msg.record.RawRequest)
|
||
m.repeaterView.focus = 0
|
||
m.repeaterView.request.Focus()
|
||
m.active = viewRepeater
|
||
default:
|
||
m.builder.textarea.SetValue(msg.record.RawRequest)
|
||
m.builder.focusIdx = 0
|
||
m.builder.applyFocus()
|
||
m.active = viewRequestBuilder
|
||
}
|
||
return m, nil
|
||
|
||
case saveToProjectMsg:
|
||
if m.projectStore == nil {
|
||
text := "нет открытого проекта — открой на вкладке Project (ctrl+o)"
|
||
switch msg.origin {
|
||
case viewRepeater:
|
||
m.repeaterView.errMsg = text
|
||
case viewResults:
|
||
if a := m.currentAttack(); a != nil {
|
||
a.results.saveMsg = text
|
||
}
|
||
case viewScanner:
|
||
m.scannerVw.errMsg = text
|
||
}
|
||
return m, nil
|
||
}
|
||
return m, projectSaveCmd(m.projectStore, msg.origin, msg.records)
|
||
|
||
case projectSaveResultMsg:
|
||
text := fmt.Sprintf("сохранено %d/%d в проект", msg.saved, msg.total)
|
||
if msg.err != nil {
|
||
text += fmt.Sprintf(" (первая ошибка: %v)", msg.err)
|
||
}
|
||
switch msg.origin {
|
||
case viewRepeater:
|
||
m.repeaterView.errMsg = ""
|
||
m.repeaterView.statusMsg = text
|
||
case viewResults:
|
||
if a := m.currentAttack(); a != nil {
|
||
a.results.saveMsg = text
|
||
}
|
||
case viewScanner:
|
||
m.scannerVw.errMsg = ""
|
||
m.scannerVw.statusMsg = text
|
||
}
|
||
return m, nil
|
||
|
||
case saveScopeMsg:
|
||
if m.projectStore == nil {
|
||
m.targetView.statusMsg = ""
|
||
m.targetView.errMsg = "нет открытого проекта — открой на вкладке Project (ctrl+o)"
|
||
return m, nil
|
||
}
|
||
return m, projectSaveScopeCmd(m.projectStore, msg.json)
|
||
|
||
case projectMetaSavedMsg:
|
||
if msg.err != nil {
|
||
m.targetView.errMsg = msg.err.Error()
|
||
m.targetView.statusMsg = ""
|
||
} else {
|
||
m.targetView.errMsg = ""
|
||
m.targetView.statusMsg = "scope-правила сохранены в проект"
|
||
}
|
||
return m, nil
|
||
|
||
case spinner.TickMsg:
|
||
// Спиннер должен анимироваться, даже если пользователь сейчас не
|
||
// на вкладке Spider — иначе переключение вкладок оборвало бы цепочку
|
||
// тиков насовсем (bubbles/spinner не переиздаёт Tick сам по себе,
|
||
// это забота вызывающего кода). Как только running становится
|
||
// false, мы просто перестаём переиздавать команду — цепочка
|
||
// естественным образом затухает без явного "стоп"-сигнала.
|
||
if !m.spiderView.running {
|
||
return m, nil
|
||
}
|
||
var cmd tea.Cmd
|
||
m.spiderView.spinner, cmd = m.spiderView.spinner.Update(msg)
|
||
return m, cmd
|
||
|
||
case dashboardTickMsg:
|
||
m.dashboardVw.SetContent(m.renderDashboard())
|
||
return m, dashboardTickCmd()
|
||
|
||
case resultsTickMsg:
|
||
if a := m.currentAttack(); a != nil {
|
||
a.results.Commit()
|
||
}
|
||
return m, resultsTickCmd()
|
||
}
|
||
|
||
// Всё, что не перехвачено выше (обычный ввод текста, стрелки,
|
||
// прокрутка), уходит в активную вкладку.
|
||
var cmd tea.Cmd
|
||
switch m.active {
|
||
case viewRequestBuilder:
|
||
m.builder, cmd = m.builder.Update(msg)
|
||
case viewResults:
|
||
if a := m.currentAttack(); a != nil {
|
||
a.results, cmd = a.results.Update(msg)
|
||
}
|
||
case viewProxy:
|
||
m.proxyView, cmd = m.proxyView.Update(msg)
|
||
case viewRepeater:
|
||
m.repeaterView, cmd = m.repeaterView.Update(msg)
|
||
case viewSpider:
|
||
m.spiderView, cmd = m.spiderView.Update(msg)
|
||
case viewTarget:
|
||
m.targetView, cmd = m.targetView.Update(msg)
|
||
case viewDecoder:
|
||
m.decoderView, cmd = m.decoderView.Update(msg)
|
||
case viewComparer:
|
||
m.comparerView, cmd = m.comparerView.Update(msg)
|
||
case viewSequencer:
|
||
m.sequencerVw, cmd = m.sequencerVw.Update(msg)
|
||
case viewDashboard:
|
||
m.dashboardVw, cmd = m.dashboardVw.Update(msg)
|
||
case viewScanner:
|
||
m.scannerVw, cmd = m.scannerVw.Update(msg)
|
||
case viewProject:
|
||
m.projectView, cmd = m.projectView.Update(msg)
|
||
case viewFuzzer:
|
||
m.fuzzerVw, cmd = m.fuzzerVw.Update(msg)
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
// currentAttack возвращает атаку, видимую сейчас на вкладке Results, либо
|
||
// nil, если атак ещё не было ни одной.
|
||
func (m Model) currentAttack() *attackRun {
|
||
if m.activeAttack < 0 || m.activeAttack >= len(m.attacks) {
|
||
return nil
|
||
}
|
||
return m.attacks[m.activeAttack]
|
||
}
|
||
|
||
// waitForResult — команда, блокирующаяся на чтении одного значения из
|
||
// канала результатов конкретной атаки (id). После обработки каждого
|
||
// resultMsg Update снова вызывает waitForResult с тем же id и каналом,
|
||
// поддерживая непрерывное чтение, пока канал не закроется (resultsDoneMsg).
|
||
// Несколько таких циклов работают параллельно — по одному на каждую
|
||
// активную атаку, что и даёт настоящую параллельность на уровне TUI.
|
||
func waitForResult(id int, ch <-chan engine.Result) tea.Cmd {
|
||
return func() tea.Msg {
|
||
r, ok := <-ch
|
||
if !ok {
|
||
return resultsDoneMsg{attackID: id}
|
||
}
|
||
return resultMsg{attackID: id, result: r}
|
||
}
|
||
}
|
||
|
||
// waitForSpiderPage — тот же паттерн непрерывного чтения канала, что и
|
||
// waitForResult/waitForProxyEntry, но для потока найденных страниц Spider'а.
|
||
func waitForSpiderPage(ch <-chan spider.Page) tea.Cmd {
|
||
return func() tea.Msg {
|
||
p, ok := <-ch
|
||
if !ok {
|
||
return spiderDoneMsg{}
|
||
}
|
||
return spiderPageMsg(p)
|
||
}
|
||
}
|
||
|
||
// waitForScanEvent — тот же паттерн непрерывного чтения канала, что и
|
||
// waitForResult/waitForSpiderPage, но для потока событий Scanner'а:
|
||
// разбирает Event.Kind и превращает его либо в scanFindingMsg, либо
|
||
// в scanProgressMsg — оба типа единообразно продолжают цикл ожидания.
|
||
func waitForScanEvent(ch <-chan scanner.Event) tea.Cmd {
|
||
return func() tea.Msg {
|
||
e, ok := <-ch
|
||
if !ok {
|
||
return scanDoneMsg{}
|
||
}
|
||
if e.Kind == scanner.EventFinding {
|
||
return scanFindingMsg(e.Finding)
|
||
}
|
||
return scanProgressMsg{done: e.Done, total: e.Total}
|
||
}
|
||
}
|
||
|
||
// waitForFuzzEvent — тот же паттерн непрерывного чтения канала, что
|
||
// и waitForScanEvent, но для потока событий Fuzzer'а.
|
||
func waitForFuzzEvent(ch <-chan fuzzer.Event) tea.Cmd {
|
||
return func() tea.Msg {
|
||
e, ok := <-ch
|
||
if !ok {
|
||
return fuzzDoneMsg{}
|
||
}
|
||
if e.Kind == fuzzer.EventCrash {
|
||
return fuzzCrashMsg(e.Crash)
|
||
}
|
||
return fuzzProgressMsg{execs: e.Execs}
|
||
}
|
||
}
|
||
|
||
// projectAutoSaveCmd сохраняет перехваченный Proxy-запрос в открытый
|
||
// проект. Вынесено в отдельную команду (а не вызов store.Insert прямо
|
||
// в Update) по той же причине, по которой в приложении вообще любой
|
||
// диск/сеть идёт через tea.Cmd: запись в SQLite — это I/O, пусть и
|
||
// быстрое, и не должна блокировать event loop TUI.
|
||
// maxRecursionBranches ограничивает, сколько под-атак запускается с
|
||
// одного завершившегося прохода — без этой узды сервер, отвечающий
|
||
// "200 OK" на любой путь без разбора (нередкая конфигурация SPA с
|
||
// фоллбэком на index.html), мог бы породить лавину под-атак: на каждом
|
||
// уровне рекурсии их число росло бы примерно как (совпадений на
|
||
// уровне)^(глубина), а не линейно.
|
||
const maxRecursionBranches = 25
|
||
|
||
// spawnRecursiveAttacks сканирует результаты завершившейся атаки на
|
||
// предмет "похоже на директорию" (совпадение со meta.statusPred) и
|
||
// запускает по под-атаке на каждую уникальную находку — параллельно,
|
||
// через tea.Batch, а не последовательно одну за другой.
|
||
func spawnRecursiveAttacks(meta recursionMeta, results []engine.Result) tea.Cmd {
|
||
var cmds []tea.Cmd
|
||
seen := make(map[string]bool)
|
||
|
||
for _, r := range results {
|
||
if len(cmds) >= maxRecursionBranches {
|
||
break
|
||
}
|
||
if r.Err != nil || len(r.Payload) == 0 {
|
||
continue
|
||
}
|
||
if meta.statusPred != nil && !meta.statusPred(r.StatusCode) {
|
||
continue
|
||
}
|
||
word := r.Payload[0]
|
||
if word == "" || seen[word] {
|
||
continue
|
||
}
|
||
seen[word] = true
|
||
cmds = append(cmds, startRecursiveAttackCmd(meta, word))
|
||
}
|
||
|
||
if len(cmds) == 0 {
|
||
return nil
|
||
}
|
||
return tea.Batch(cmds...)
|
||
}
|
||
|
||
// startRecursiveAttackCmd строит под-атаку внутри найденной директории
|
||
// discoveredWord: вставляет "discoveredWord/" перед точкой вставки в
|
||
// исходном сыром запросе (engine.InsertBeforeMarker) и пересобирает всё
|
||
// с нуля тем же путём, что и requestBuilderModel.runCmd — свежие
|
||
// генераторы из тех же specs, тот же движок с теми же параметрами.
|
||
func startRecursiveAttackCmd(meta recursionMeta, discoveredWord string) tea.Cmd {
|
||
return func() tea.Msg {
|
||
newRaw, err := engine.InsertBeforeMarker(meta.rawTemplate, meta.pointIndex, discoveredWord+"/")
|
||
if err != nil {
|
||
return errMsg{fmt.Errorf("рекурсия: %w", err)}
|
||
}
|
||
|
||
clean, points, err := engine.ParseMarkers(newRaw)
|
||
if err != nil {
|
||
return errMsg{fmt.Errorf("рекурсия: маркеры §...§: %w", err)}
|
||
}
|
||
if meta.pointIndex >= len(points) {
|
||
return errMsg{fmt.Errorf("рекурсия: индекс точки вставки вышел за границы после подстановки")}
|
||
}
|
||
|
||
gens := make([]payload.Generator, 0, len(meta.specs))
|
||
for _, spec := range meta.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'а")}
|
||
}
|
||
|
||
eng := engine.New(engine.Config{
|
||
Concurrency: meta.concurrency,
|
||
RateLimit: meta.rateLimit,
|
||
Timeout: meta.timeout,
|
||
InsecureSkipVerify: meta.insecure,
|
||
ForceScheme: meta.forceScheme,
|
||
})
|
||
|
||
base := make([]string, len(points))
|
||
total := engine.EstimateTotal(meta.mode, points, gens)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
ch, err := eng.Run(ctx, meta.mode, clean, points, gens, base)
|
||
if err != nil {
|
||
cancel()
|
||
return errMsg{fmt.Errorf("рекурсия: %w", err)}
|
||
}
|
||
|
||
nextMeta := meta
|
||
nextMeta.rawTemplate = newRaw
|
||
nextMeta.depth = meta.depth + 1
|
||
|
||
return attackStartedMsg{ch: ch, cancel: cancel, total: total, recursion: &nextMeta}
|
||
}
|
||
}
|
||
|
||
func projectAutoSaveCmd(store *project.Store, entry proxy.Entry) tea.Cmd {
|
||
return func() tea.Msg {
|
||
host := ""
|
||
if u, err := url.Parse(entry.URL); err == nil {
|
||
host = u.Host
|
||
}
|
||
_, err := store.Insert(project.Record{
|
||
Source: "proxy",
|
||
Host: host,
|
||
Method: entry.Method,
|
||
URL: entry.URL,
|
||
Status: entry.StatusCode,
|
||
ContentLength: entry.Length,
|
||
RawRequest: entry.Raw,
|
||
RawResponse: entry.ResponseBody,
|
||
})
|
||
if err != nil {
|
||
return projectErrMsg{fmt.Errorf("автосохранение в проект: %w", err)}
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// projectSaveCmd (ctrl+p на Repeater/Results/Scanner) сохраняет пачку
|
||
// записей в открытый проект. Продолжает сохранять остальные записи,
|
||
// даже если одна не прошла — не хочется терять весь батч из-за одной
|
||
// плохой строки; первую встреченную ошибку возвращает для показа
|
||
// пользователю, но она не блокирует остальные вставки.
|
||
func projectSaveCmd(store *project.Store, origin view, records []project.Record) tea.Cmd {
|
||
return func() tea.Msg {
|
||
saved := 0
|
||
var firstErr error
|
||
for _, r := range records {
|
||
if _, err := store.Insert(r); err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
continue
|
||
}
|
||
saved++
|
||
}
|
||
return projectSaveResultMsg{origin: origin, saved: saved, total: len(records), err: firstErr}
|
||
}
|
||
}
|
||
|
||
// applyProjectScope пытается загрузить scope-правила из meta[scope_rules]
|
||
// проекта и применить их к targetView. Тихо ничего не делает, если
|
||
// правил нет (новый проект) или запись повреждена/от несовместимой
|
||
// версии — это не должно ронять открытие проекта, просто scope
|
||
// останется пустым, как для нового проекта. Переиспользуется и при
|
||
// открытии через ctrl+o (projectOpenedMsg), и при автозагрузке
|
||
// последнего проекта в New().
|
||
func applyProjectScope(tv *targetModel, store *project.Store) {
|
||
data, ok, err := store.LoadMeta("scope_rules")
|
||
if err != nil || !ok {
|
||
return
|
||
}
|
||
var rules []sitemap.Rule
|
||
if err := json.Unmarshal([]byte(data), &rules); err != nil {
|
||
return
|
||
}
|
||
newScope := sitemap.NewScope()
|
||
for _, r := range rules {
|
||
_ = newScope.Add(r.Pattern, r.Exclude)
|
||
}
|
||
tv.scope = newScope
|
||
tv.rebuildTree()
|
||
}
|
||
|
||
// pushScopeToProxy пересобирает независимый снапшот из scope.Rules() и
|
||
// передаёт его в живой proxy.Server.SetScope. Снапшот, а не тот же
|
||
// *sitemap.Scope, что мутирует targetModel — иначе TUI-горутина (Add/
|
||
// RemoveAt, обычный append по срезу) и горутины прокси (Matches, чтение
|
||
// того же среза на каждый запрос) конкурировали бы за один объект без
|
||
// синхронизации. Не паникует и просто ничего не делает, если server==nil
|
||
// (прокси не поднялся) — тот же принцип терпимости к отсутствующему
|
||
// прокси, что и в остальном приложении.
|
||
func pushScopeToProxy(server *proxy.Server, scope *sitemap.Scope) {
|
||
if server == nil {
|
||
return
|
||
}
|
||
snapshot := sitemap.NewScope()
|
||
for _, r := range scope.Rules() {
|
||
_ = snapshot.Add(r.Pattern, r.Exclude)
|
||
}
|
||
server.SetScope(snapshot)
|
||
}
|
||
|
||
// projectSaveScopeCmd (ctrl+p на Target) пишет сериализованные
|
||
// scope-правила в meta[scope_rules] открытого проекта.
|
||
func projectSaveScopeCmd(store *project.Store, jsonData string) tea.Cmd {
|
||
return func() tea.Msg {
|
||
if err := store.SaveMeta("scope_rules", jsonData); err != nil {
|
||
return projectMetaSavedMsg{err: err}
|
||
}
|
||
return projectMetaSavedMsg{}
|
||
}
|
||
}
|
||
|
||
// waitForProxyEntry — тот же паттерн непрерывного чтения канала,
|
||
// что и waitForResult, но для потока перехваченных прокси-запросов,
|
||
// который живёт всё время работы TUI, а не только во время атаки.
|
||
func waitForProxyEntry(ch <-chan proxy.Entry) tea.Cmd {
|
||
return func() tea.Msg {
|
||
e, ok := <-ch
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return proxyEntryMsg(e)
|
||
}
|
||
}
|
||
|
||
func (m Model) View() string {
|
||
tabs := renderTabs(m.active, m.builder.IsModified(), m.repeaterView.IsModified())
|
||
var body string
|
||
switch m.active {
|
||
case viewRequestBuilder:
|
||
body = m.builder.View()
|
||
case viewResults:
|
||
body = m.resultsView()
|
||
case viewProxy:
|
||
body = fmt.Sprintf(
|
||
"Прокси: %s CA: %s (импортируй один раз в браузер)\n\n%s",
|
||
m.proxyAddr, m.caPath, m.proxyView.View(),
|
||
)
|
||
case viewRepeater:
|
||
body = m.repeaterView.View()
|
||
case viewSpider:
|
||
body = m.spiderView.View()
|
||
case viewTarget:
|
||
body = m.targetView.View()
|
||
case viewDecoder:
|
||
body = m.decoderView.View()
|
||
case viewComparer:
|
||
body = m.comparerView.View()
|
||
case viewSequencer:
|
||
body = m.sequencerVw.View()
|
||
case viewDashboard:
|
||
body = m.dashboardVw.View()
|
||
case viewScanner:
|
||
body = m.scannerVw.View()
|
||
case viewProject:
|
||
body = m.projectView.View()
|
||
case viewFuzzer:
|
||
body = m.fuzzerVw.View()
|
||
}
|
||
|
||
// Раньше здесь body каждой вкладки (кроме Dashboard) оборачивалась
|
||
// в panel() — общая рамка вокруг всего тела. Откатил: рамка добавляет
|
||
// 4 строки "хрома" (верхняя граница + строка заголовка + нижняя
|
||
// граница + разделитель), и я дважды подряд не смог вслепую подобрать
|
||
// для неё правильный бюджет высоты — то меню внизу вкладки обрезалось,
|
||
// то сама рамка вылезала за край экрана на вкладках с длинным
|
||
// содержимым (Comparer, Scanner). Без реального терминала под рукой
|
||
// подбор точных чисел — угадывание, а тут уже дважды не угадал.
|
||
// Dashboard со своими собственными панелями (там это подтверждённо
|
||
// работает) и стилизованный таб-бар (renderTabs) остаются — они не
|
||
// зависят от этого бюджета так жёстко.
|
||
return tabs + "\n\n" + body
|
||
}
|
||
|
||
// resultsView рендерит вкладку Results: если атак ещё не было — подсказку,
|
||
// иначе заголовок с номером текущей атаки среди всех и её resultsModel.
|
||
func (m Model) resultsView() string {
|
||
if len(m.attacks) == 0 {
|
||
return "Атак ещё не было. Заполни форму на вкладке Request и нажми ctrl+r."
|
||
}
|
||
|
||
a := m.attacks[m.activeAttack]
|
||
status := "выполняется"
|
||
if !a.running {
|
||
status = "завершена"
|
||
}
|
||
header := fmt.Sprintf(
|
||
"Атака #%d (%d/%d вкладок, %s, %d%s результатов)\n[ / ] — переключение между атаками · esc — отменить текущую\n\n",
|
||
a.id, m.activeAttack+1, len(m.attacks), status, len(a.results.all), progressSuffix(len(a.results.all), a.total),
|
||
)
|
||
return header + a.results.View()
|
||
}
|
||
|
||
// progressSuffix форматирует "N/Total (XX%)" — если total неизвестен
|
||
// (0, напр. для нулевого генератора — на практике не бывает после
|
||
// валидации в форме, но на всякий случай), просто ничего не добавляет.
|
||
func progressSuffix(done, total int) string {
|
||
if total <= 0 {
|
||
return ""
|
||
}
|
||
pct := float64(done) / float64(total) * 100
|
||
if pct > 100 {
|
||
pct = 100
|
||
}
|
||
return fmt.Sprintf("/%d (%.0f%%)", total, pct)
|
||
}
|
||
|
||
// renderDashboard собирает метрики самого процесса burterm: память и
|
||
// горутины из runtime, состояние всех активных Intruder-атак (с грубым
|
||
// req/s — результаты минус время с запуска, без скользящего окна),
|
||
// счётчик перехваченного Proxy-трафика и статус Spider. Это метрики
|
||
// самого инструмента, а не хоста — реальный CPU% системы потребовал бы
|
||
// платформенно-специфичного чтения /proc/stat, что осознанно не стали
|
||
// делать ради переносимости этого файла.
|
||
func (m Model) renderDashboard() string {
|
||
var mem runtime.MemStats
|
||
runtime.ReadMemStats(&mem)
|
||
|
||
width := m.width
|
||
if width <= 0 {
|
||
width = 80
|
||
}
|
||
|
||
var processBody strings.Builder
|
||
processBody.WriteString(fmt.Sprintf("Аптайм: %s\n", time.Since(m.programStart).Round(time.Second)))
|
||
processBody.WriteString(fmt.Sprintf("Горутин: %d\n", runtime.NumGoroutine()))
|
||
processBody.WriteString(fmt.Sprintf("Heap alloc: %s\n", humanBytes(mem.HeapAlloc)))
|
||
processBody.WriteString(fmt.Sprintf("Sys (от ОС): %s\n", humanBytes(mem.Sys)))
|
||
processBody.WriteString(fmt.Sprintf("Аллокаций: %s\n", humanBytes(mem.TotalAlloc)))
|
||
processBody.WriteString(fmt.Sprintf("GC-циклов: %d", mem.NumGC))
|
||
|
||
var intruderBody strings.Builder
|
||
if len(m.attacks) == 0 {
|
||
intruderBody.WriteString("(атак ещё не было)")
|
||
} else {
|
||
for i, a := range m.attacks {
|
||
status := "выполняется"
|
||
if !a.running {
|
||
status = "завершена"
|
||
}
|
||
elapsed := time.Since(a.startedAt)
|
||
count := len(a.results.all)
|
||
rps := 0.0
|
||
if elapsed.Seconds() > 0 {
|
||
rps = float64(count) / elapsed.Seconds()
|
||
}
|
||
if i > 0 {
|
||
intruderBody.WriteByte('\n')
|
||
}
|
||
intruderBody.WriteString(fmt.Sprintf("#%-3d %-11s %d%s %.1f/с",
|
||
a.id, status, count, progressSuffix(count, a.total), rps))
|
||
}
|
||
}
|
||
|
||
// Двухколоночная раскладка только на достаточно широком терминале —
|
||
// на мобильном SSH-клиенте панели бок о бок просто не влезут и
|
||
// съедутся, поэтому ниже порога честно складываем их вертикально.
|
||
twoCol := width >= 90
|
||
var row1 string
|
||
if twoCol {
|
||
colWidth := width/2 - 6
|
||
if colWidth < 20 {
|
||
colWidth = 20
|
||
}
|
||
row1 = lipgloss.JoinHorizontal(lipgloss.Top,
|
||
panel("process", processBody.String(), colWidth),
|
||
panel("intruder", intruderBody.String(), colWidth),
|
||
)
|
||
} else {
|
||
fullWidth := width - 6
|
||
if fullWidth < 20 {
|
||
fullWidth = 20
|
||
}
|
||
row1 = lipgloss.JoinVertical(lipgloss.Left,
|
||
panel("process", processBody.String(), fullWidth),
|
||
panel("intruder", intruderBody.String(), fullWidth),
|
||
)
|
||
}
|
||
|
||
var modulesBody strings.Builder
|
||
modulesBody.WriteString(fmt.Sprintf("Proxy перехвачено: %d\n", len(m.proxyView.entries)))
|
||
|
||
spiderStatus := "не запущен"
|
||
if m.spiderView.running {
|
||
spiderStatus = "выполняется"
|
||
}
|
||
modulesBody.WriteString(fmt.Sprintf("Spider %-12s страниц: %d\n", spiderStatus, m.spiderView.count))
|
||
|
||
scanStatus := "не запущен"
|
||
if m.scannerVw.running {
|
||
scanStatus = "выполняется"
|
||
}
|
||
modulesBody.WriteString(fmt.Sprintf("Scanner %-12s находок: %d\n", scanStatus, m.scannerVw.count))
|
||
|
||
if m.projectStore != nil {
|
||
count, err := m.projectStore.Count()
|
||
if err != nil {
|
||
modulesBody.WriteString(fmt.Sprintf("Project %s (ошибка подсчёта: %v)\n", m.projectStore.Path, err))
|
||
} else {
|
||
modulesBody.WriteString(fmt.Sprintf("Project %s, записей: %d\n", m.projectStore.Path, count))
|
||
}
|
||
} else {
|
||
modulesBody.WriteString("Project не открыт\n")
|
||
}
|
||
|
||
fuzzStatus := "не запущен"
|
||
if m.fuzzerVw.running {
|
||
fuzzStatus = "выполняется"
|
||
}
|
||
modulesBody.WriteString(fmt.Sprintf("Fuzzer %-12s execs: %d, крашей: %d", fuzzStatus, m.fuzzerVw.execs, m.fuzzerVw.crashes))
|
||
|
||
modulesWidth := width - 6
|
||
if modulesWidth < 20 {
|
||
modulesWidth = 20
|
||
}
|
||
modulesPanel := panel("modules", modulesBody.String(), modulesWidth)
|
||
|
||
var out strings.Builder
|
||
out.WriteString(renderLogo())
|
||
out.WriteString("\n\n")
|
||
out.WriteString(row1)
|
||
out.WriteString("\n")
|
||
out.WriteString(modulesPanel)
|
||
return out.String()
|
||
}
|
||
|
||
// humanBytes форматирует байты в KiB/MiB/GiB — обычный идиоматичный для
|
||
// Go способ, без внешних зависимостей.
|
||
func humanBytes(n uint64) string {
|
||
const unit = 1024
|
||
if n < unit {
|
||
return fmt.Sprintf("%d B", n)
|
||
}
|
||
div, exp := uint64(unit), 0
|
||
for n2 := n / unit; n2 >= unit; n2 /= unit {
|
||
div *= unit
|
||
exp++
|
||
}
|
||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||
}
|