burterm/internal/tui/model.go
2026-09-14 10:55:07 +03:00

1413 lines
53 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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])
}