497 lines
20 KiB
Go
497 lines
20 KiB
Go
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()
|
||
}
|