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