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() }