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

497 lines
20 KiB
Go
Raw Permalink 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
import (
"fmt"
neturl "net/url"
"regexp"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/engine"
"github.com/r3g1tpr0cs/burterm/internal/project"
)
// resultsModel — результаты одной атаки.
//
// Это единственное место в приложении, которое реально должно
// выдерживать сотни тысяч — миллионы элементов (атака по большому
// словарю), поэтому устроено иначе, чем можно было бы ожидать по
// аналогии с остальными вкладками:
//
// - Предикаты фильтров (statusPred/lengthPred/...) компилируются один
// раз при изменении соответствующего текстового поля (recompileFilters)
// и кэшируются — а не парсятся заново на каждый входящий результат.
// Раньше именно это парсилось внутри refresh() на каждый AddResult.
// - content — строка уже отфильтрованных и отформатированных строк,
// пополняется ИНКРЕМЕНТАЛЬНО (WriteString одной новой строки), а не
// пересобирается с нуля strings.Join по всем накопленным результатам
// на каждый новый (это и было O(N) на входящий результат, то есть
// O(N²) суммарно за всю атаку — на миллионе результатов это заводит
// TUI намертво независимо от того, насколько быстра сеть).
// - Сам вызов viewport.SetContent — тоже не бесплатный: bubbles
// перестраивает список строк для скролла на каждый вызов, то есть
// стоит O(текущей длины содержимого). Поэтому AddResult НЕ дёргает
// SetContent на каждый результат — только помечает dirty=true;
// реальный рендер происходит по таймеру (см. resultsTickCmd в
// model.go, обновляет только ту атаку, что сейчас видна на экране)
// и принудительно — при завершении атаки и при переключении на её
// вкладку, чтобы не показывать устаревшее содержимое.
//
// Полный пересбор (rebuildAll, O(N)) происходит только при смене
// фильтров — это ограничено скоростью печати человека, не скоростью
// поступления результатов из сети, так что O(N) там не проблема.
type resultsModel struct {
viewport viewport.Model
all []engine.Result
matched int
attacking bool
dirty bool // есть непоказанные изменения content — см. doc пакета выше
statusFilter textinput.Model
lengthFilter textinput.Model
wordFilter textinput.Model // число слов в теле ответа — аналог -mw/-fw у ffuf
lineFilter textinput.Model // число строк в теле ответа — аналог -ml/-fl у ffuf
grepFilter textinput.Model
grepInvert bool
highlightFilter textinput.Model // своё regex-правило подсветки строк
filterErr string
saveMsg string // результат последнего ctrl+p ("сохранено N/M в проект" и т.п.)
// Кэшированные скомпилированные фильтры — см. recompileFilters.
statusPred engine.StatusFilter
lengthPred engine.LengthFilter
wordPred engine.WordFilter
linePred engine.LineFilter
grepNeedle string
highlightRe *regexp.Regexp
content strings.Builder // весь текст viewport целиком, включая заголовок — см. doc пакета
focus int // 0 статус, 1 длина, 2 слова, 3 строки, 4 grep, 5 подсветка, 6 viewport (скролл)
}
func newResults() resultsModel {
statusIn := textinput.New()
statusIn.Placeholder = "напр. 200 или 200,301 или 200-299"
statusIn.CharLimit = 128
lengthIn := textinput.New()
lengthIn.Placeholder = "напр. >1000 или 100-500"
lengthIn.CharLimit = 64
wordIn := textinput.New()
wordIn.Placeholder = "напр. 10 или >5 или 3-8"
wordIn.CharLimit = 64
lineIn := textinput.New()
lineIn.Placeholder = "напр. 1 или >2"
lineIn.CharLimit = 64
grepIn := textinput.New()
grepIn.Placeholder = "подстрока в теле ответа (первые 4КБ)"
grepIn.CharLimit = 256
highlightIn := textinput.New()
highlightIn.Placeholder = "regex — подсветить совпавшие строки по телу ответа"
highlightIn.CharLimit = 256
m := resultsModel{
viewport: viewport.New(0, 0),
statusFilter: statusIn,
lengthFilter: lengthIn,
wordFilter: wordIn,
lineFilter: lineIn,
grepFilter: grepIn,
highlightFilter: highlightIn,
}
m.recompileFilters() // сразу рендерит пустой viewport с одним заголовком
return m
}
// SetSize подгоняет viewport под размер терминала, оставляя место
// сверху под шесть строк фильтров (статус/длина/слова/строки/grep/подсветка).
func (m *resultsModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
m.viewport.Width = width
// 11 фиксированных строк: 6 строк фильтров (статус/длина/слова/
// строки/grep/highlight) + блок "Показано N из M"+пустая (2) +
// разделитель перед viewport... на самом деле viewport начинается
// сразу после "Показано\n\n", разделитель уже в этих двух строках —
// плюс разделитель+двухстрочный футер (3). Итого 6+2+3 = 11.
vh := height - 11
if vh < 3 {
vh = 3
}
m.viewport.Height = vh
}
// Reset очищает список и фильтры перед новым запуском атаки.
func (m *resultsModel) Reset() {
m.all = nil
m.matched = 0
m.attacking = true
m.dirty = false
m.recompileFilters()
}
// AddResult дописывает результат в m.all и, если он проходит текущие
// (уже скомпилированные) фильтры, приписывает готовую строку к content.
// Никакого парсинга фильтров и никакого viewport.SetContent здесь —
// оба стоят дороже одного результата и дёргаются отдельно, дросселированно
// (см. doc пакета выше). Это и есть амортизированный O(1) на результат.
func (m *resultsModel) AddResult(r engine.Result) {
m.all = append(m.all, r)
if passes, highlighted := m.matches(r); passes {
m.matched++
m.content.WriteByte('\n')
m.content.WriteString(formatResult(len(m.all), r, highlighted))
}
m.dirty = true
}
// Finish помечает атаку завершённой и форсирует рендер — чтобы самые
// последние результаты были видны сразу, не дожидаясь следующего тика.
func (m *resultsModel) Finish() {
m.attacking = false
m.Commit()
}
// Commit переносит накопленный content во viewport, если есть что
// переносить (dirty). Вызывается по таймеру (см. resultsTickCmd в
// model.go) для атаки, что сейчас на экране, и принудительно — из
// Finish и при переключении на вкладку/атаку с этим resultsModel.
func (m *resultsModel) Commit() {
if !m.dirty {
return
}
m.viewport.SetContent(m.content.String())
m.viewport.GotoBottom()
m.dirty = false
}
// matches проверяет результат против уже скомпилированных фильтров
// (без парсинга текста — предикаты закэшированы в recompileFilters) и
// заодно решает, должна ли строка быть подсвечена.
func (m *resultsModel) matches(r engine.Result) (passes, highlighted bool) {
if m.statusPred != nil && !m.statusPred(r.StatusCode) {
return false, false
}
if m.lengthPred != nil && !m.lengthPred(r.Length) {
return false, false
}
if m.wordPred != nil && !m.wordPred(int64(countWords(r.Body))) {
return false, false
}
if m.linePred != nil && !m.linePred(int64(countLines(r.Body))) {
return false, false
}
if m.grepNeedle != "" {
contains := strings.Contains(strings.ToLower(r.Body), m.grepNeedle)
if contains == m.grepInvert {
return false, false
}
}
highlighted = m.highlightRe != nil && m.highlightRe.MatchString(r.Body)
return true, highlighted
}
// recompileFilters разбирает текст всех полей фильтра в кэшированные
// предикаты (см. поля statusPred и т.д.) и запускает полный пересбор —
// вызывается при любом изменении текста фильтра. Это единственное
// место, где парсинг фильтров вообще происходит; AddResult его больше
// не трогает.
func (m *resultsModel) recompileFilters() {
var statusErr, lengthErr, wordErr, lineErr error
m.statusPred, statusErr = engine.ParseStatusFilter(m.statusFilter.Value())
m.lengthPred, lengthErr = engine.ParseLengthFilter(m.lengthFilter.Value())
m.wordPred, wordErr = engine.ParseWordFilter(m.wordFilter.Value())
m.linePred, lineErr = engine.ParseLineFilter(m.lineFilter.Value())
m.grepNeedle = strings.ToLower(strings.TrimSpace(m.grepFilter.Value()))
m.highlightRe = nil
highlightErr := ""
if pattern := strings.TrimSpace(m.highlightFilter.Value()); pattern != "" {
re, err := regexp.Compile(pattern)
if err != nil {
highlightErr = "подсветка: " + err.Error()
} else {
m.highlightRe = re
}
}
var errParts []string
if statusErr != nil {
errParts = append(errParts, "статус: "+statusErr.Error())
}
if lengthErr != nil {
errParts = append(errParts, "длина: "+lengthErr.Error())
}
if wordErr != nil {
errParts = append(errParts, "слова: "+wordErr.Error())
}
if lineErr != nil {
errParts = append(errParts, "строки: "+lineErr.Error())
}
if highlightErr != "" {
errParts = append(errParts, highlightErr)
}
m.filterErr = strings.Join(errParts, " · ")
m.rebuildAll()
}
// rebuildAll — единственное O(N)-место: полностью пересобирает content
// из m.all с текущими (уже скомпилированными) предикатами и сразу же
// рендерит во viewport (это вызов из пользовательского ввода — печать
// в поле фильтра, а не входящий поток результатов, так что синхронный
// SetContent здесь уместен и не нуждается в дросселировании).
func (m *resultsModel) rebuildAll() {
m.content.Reset()
header := fmt.Sprintf("%-5s %-6s %-6s %-8s %-10s %s", "#", "СТАТУС", "ТИП", "ДЛИНА", "ВРЕМЯ", "PAYLOAD")
sep := strings.Repeat("─", len(header))
m.content.WriteString(header)
m.content.WriteByte('\n')
m.content.WriteString(sep)
matched := 0
for i, r := range m.all {
passes, highlighted := m.matches(r)
if !passes {
continue
}
matched++
m.content.WriteByte('\n')
m.content.WriteString(formatResult(i+1, r, highlighted))
}
m.matched = matched
m.viewport.SetContent(m.content.String())
}
// countWords/countLines — то же самое, что считают -mw/-ml у ffuf: слова
// как непрерывные последовательности не-пробельных символов
// (strings.Fields), строки как число переносов +1 (пустое тело — 0 строк,
// а не 1, иначе "нет тела" и "одна пустая строка" стали бы неразличимы
// фильтром). Считается по r.Body — то есть по капированным (до 4КБ)
// первым байтам тела, не по полному ответу; для типичных страниц-404
// или листингов директорий этого достаточно, но на очень длинных
// страницах число может не совпасть с тем, что показал бы браузер.
func countWords(s string) int {
return len(strings.Fields(s))
}
func countLines(s string) int {
if s == "" {
return 0
}
return strings.Count(s, "\n") + 1
}
// formatResult рендерит одну строку таблицы результатов. Ошибочные
// запросы (таймаут, обрыв соединения и т.п.) помечаются ERR вместо
// статус-кода — это не то же самое, что HTTP-ответ с ошибкой на стороне
// сервера, который отобразится обычным StatusCode (403/500/...).
//
// highlighted включает единый styleHighlight на всю строку вместо
// обычной раскраски статуса/типа контента — см. комментарий у
// styleHighlight в style.go про причину не комбинировать стили инлайн.
func formatResult(n int, r engine.Result, highlighted bool) string {
ct := contentTypeLabel(r.Headers)
if ct == "" {
ct = "-"
}
if r.Err != nil {
plain := fmt.Sprintf("%-5d %-6s %-6s %-8s %-10s %v (%v)", n, "ERR", "-", "-", "-", r.Payload, r.Err)
if highlighted {
return styleHighlight.Render(plain)
}
status := styleErrStatus.Render(fmt.Sprintf("%-6s", "ERR"))
return fmt.Sprintf("%-5d %s %-6s %-8s %-10s %v (%v)", n, status, "-", "-", "-", r.Payload, r.Err)
}
plain := fmt.Sprintf("%-5d %-6d %-6s %-8d %-10s %v", n, r.StatusCode, ct, r.Length, r.Duration.Round(time.Millisecond), r.Payload)
if highlighted {
return styleHighlight.Render(plain)
}
status := statusStyle(r.StatusCode).Render(fmt.Sprintf("%-6d", r.StatusCode))
ctStyled := styleDim.Render(fmt.Sprintf("%-6s", ct))
return fmt.Sprintf("%-5d %s %s %-8d %-10s %v", n, status, ctStyled, r.Length, r.Duration.Round(time.Millisecond), r.Payload)
}
// saveToProjectCmd (ctrl+p) сохраняет в открытый проект только то, что
// сейчас проходит фильтры — "видишь на экране, то и сохраняешь", а не
// молча дублирует весь список независимо от того, что отфильтровано.
// Переиспользует уже скомпилированные предикаты (не парсит текст полей
// заново) — то же, чем пользуется рендер, гарантированно то же самое,
// что видно на экране в момент нажатия ctrl+p.
func (m resultsModel) saveToProjectCmd() tea.Cmd {
statusPred := m.statusPred
lengthPred := m.lengthPred
wordPred := m.wordPred
linePred := m.linePred
grepNeedle := m.grepNeedle
grepInvert := m.grepInvert
all := append([]engine.Result(nil), m.all...)
return func() tea.Msg {
var records []project.Record
for _, r := range all {
if statusPred != nil && !statusPred(r.StatusCode) {
continue
}
if lengthPred != nil && !lengthPred(r.Length) {
continue
}
if wordPred != nil && !wordPred(int64(countWords(r.Body))) {
continue
}
if linePred != nil && !linePred(int64(countLines(r.Body))) {
continue
}
if grepNeedle != "" {
contains := strings.Contains(strings.ToLower(r.Body), grepNeedle)
if contains == grepInvert {
continue
}
}
host := ""
if u, err := neturl.Parse(r.URL); err == nil {
host = u.Host
}
records = append(records, project.Record{
Source: "intruder",
Host: host,
Method: r.Method,
URL: r.URL,
Status: r.StatusCode,
ContentLength: r.Length,
RawRequest: fmt.Sprintf("%s %s\npayload: %v", r.Method, r.URL, r.Payload),
RawResponse: r.Body,
})
}
return saveToProjectMsg{origin: viewResults, records: records}
}
}
func (m resultsModel) Update(msg tea.Msg) (resultsModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 7
m.statusFilter.Blur()
m.lengthFilter.Blur()
m.wordFilter.Blur()
m.lineFilter.Blur()
m.grepFilter.Blur()
m.highlightFilter.Blur()
switch m.focus {
case 0:
m.statusFilter.Focus()
case 1:
m.lengthFilter.Focus()
case 2:
m.wordFilter.Focus()
case 3:
m.lineFilter.Focus()
case 4:
m.grepFilter.Focus()
case 5:
m.highlightFilter.Focus()
}
return m, nil
case "ctrl+i":
m.grepInvert = !m.grepInvert
m.recompileFilters()
return m, nil
case "ctrl+p":
return m, m.saveToProjectCmd()
}
}
var cmd tea.Cmd
switch m.focus {
case 0:
m.statusFilter, cmd = m.statusFilter.Update(msg)
m.recompileFilters()
case 1:
m.lengthFilter, cmd = m.lengthFilter.Update(msg)
m.recompileFilters()
case 2:
m.wordFilter, cmd = m.wordFilter.Update(msg)
m.recompileFilters()
case 3:
m.lineFilter, cmd = m.lineFilter.Update(msg)
m.recompileFilters()
case 4:
m.grepFilter, cmd = m.grepFilter.Update(msg)
m.recompileFilters()
case 5:
m.highlightFilter, cmd = m.highlightFilter.Update(msg)
m.recompileFilters()
case 6:
m.viewport, cmd = m.viewport.Update(msg)
}
return m, cmd
}
func (m resultsModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
invert := ""
if m.grepInvert {
invert = " (инверсия, ctrl+i)"
} else {
invert = " (ctrl+i — инвертировать)"
}
var b strings.Builder
b.WriteString(focusMark(0) + "Статус: ")
b.WriteString(m.statusFilter.View())
b.WriteString("\n")
b.WriteString(focusMark(1) + "Длина: ")
b.WriteString(m.lengthFilter.View())
b.WriteString("\n")
b.WriteString(focusMark(2) + "Слова: ")
b.WriteString(m.wordFilter.View())
b.WriteString("\n")
b.WriteString(focusMark(3) + "Строки: ")
b.WriteString(m.lineFilter.View())
b.WriteString("\n")
b.WriteString(focusMark(4) + "Grep: ")
b.WriteString(m.grepFilter.View())
b.WriteString(invert)
b.WriteString("\n")
b.WriteString(focusMark(5) + "Highlight: ")
b.WriteString(m.highlightFilter.View())
b.WriteString("\n")
if m.filterErr != "" {
b.WriteString("Ошибка фильтра: " + m.filterErr + "\n")
}
if m.saveMsg != "" {
b.WriteString(m.saveMsg + "\n")
}
b.WriteString(fmt.Sprintf("Показано %d из %d\n\n", m.matched, len(m.all)))
b.WriteString(focusMark(6) + m.viewport.View())
b.WriteString("\n\ntab — между полями · ctrl+i — инвертировать grep · ctrl+p — сохранить показанное в проект\n")
b.WriteString("[ / ] — другие атаки · esc — отменить\n")
return b.String()
}