package tui import ( "context" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/r3g1tpr0cs/burterm/internal/fuzzer" ) // fuzzerModel — вкладка Fuzzer: путь к целевому бинарнику, аргументы // (с плейсхолдером "@@" — заменяется на файл с мутированным вводом, // это отдельный от Intruder-маркеров §...§ синтаксис), seed-файлы, число // воркеров и таймаут на запуск. Живой лог крашей пополняется по мере // поступления; у фаззинга, как и у краулинга, нет заранее известного // "total" — вместо процента честный спиннер + счётчик execs/сек. type fuzzerModel struct { targetInput textinput.Model argsInput textinput.Model seedsInput textinput.Model workersInput textinput.Model timeoutInput textinput.Model useStdin bool viewport viewport.Model spinner spinner.Model running bool startedAt time.Time execs int64 crashes int crashDir string lines []string errMsg string statusMsg string focus int // 0 target, 1 args, 2 seeds, 3 workers, 4 timeout, 5 viewport } func newFuzzerView() fuzzerModel { target := textinput.New() target.Placeholder = "/путь/к/цели" target.CharLimit = 512 target.Focus() args := textinput.New() args.Placeholder = "@@ — заменяется на файл с мутированным вводом (ctrl+i — вместо этого через stdin)" args.CharLimit = 512 seeds := textinput.New() seeds.Placeholder = "seed1.bin,seed2.bin — через запятую" seeds.CharLimit = 1024 workers := textinput.New() workers.Placeholder = "4" workers.CharLimit = 4 timeout := textinput.New() timeout.Placeholder = "2" timeout.CharLimit = 4 sp := spinner.New() sp.Spinner = spinner.Dot return fuzzerModel{ targetInput: target, argsInput: args, seedsInput: seeds, workersInput: workers, timeoutInput: timeout, viewport: viewport.New(0, 0), spinner: sp, } } func (m *fuzzerModel) SetSize(width, height int) { if width < 0 { width = 0 } if height < 0 { height = 0 } m.viewport.Width = width // 20 фиксированных строк вокруг viewport (построчный подсчёт по // View(): 5 полей × (метка+поле+разделитель) = 15, плюс разделитель // перед статусом (1), строка статуса (1), разделитель перед viewport // (1), разделитель+футер(две строки) (3) — итого 20. Раньше стояло // -12 — критически недосчитано с самого начала, безотносительно // разбиения футера. Оговорка: это минимум для "чистого" состояния — // если показаны ещё и "Краши сохраняются в:"/ошибка/статус (обычное // дело после первого запуска), реальная высота на 1-3 строки больше // этого минимума. vh := height - 20 if vh < 3 { vh = 3 } m.viewport.Height = vh } // Reset очищает лог крашей и счётчики перед новым запуском. func (m *fuzzerModel) Reset() { m.lines = nil m.crashes = 0 m.execs = 0 m.running = true m.startedAt = time.Now() m.viewport.SetContent("") } func (m *fuzzerModel) Finish() { m.running = false } // AddCrash дописывает находку в лог. func (m *fuzzerModel) AddCrash(c fuzzer.Crash) { m.crashes++ var b strings.Builder b.WriteString(fmt.Sprintf("#%-3d %-10s %s\n", m.crashes, c.Signal, c.Path)) if c.Stderr != "" { b.WriteString(" stderr: " + firstLine(c.Stderr) + "\n") } m.lines = append(m.lines, b.String()) m.viewport.SetContent(strings.Join(m.lines, "\n")) m.viewport.GotoBottom() } func firstLine(s string) string { if idx := strings.IndexByte(s, '\n'); idx >= 0 { return s[:idx] } return s } func (m fuzzerModel) Update(msg tea.Msg) (fuzzerModel, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "tab": m.focus = (m.focus + 1) % 6 m.targetInput.Blur() m.argsInput.Blur() m.seedsInput.Blur() m.workersInput.Blur() m.timeoutInput.Blur() switch m.focus { case 0: m.targetInput.Focus() case 1: m.argsInput.Focus() case 2: m.seedsInput.Focus() case 3: m.workersInput.Focus() case 4: m.timeoutInput.Focus() } return m, nil case "ctrl+i": m.useStdin = !m.useStdin return m, nil case "ctrl+r": if m.running { // Фаззер уже идёт — второй запуск игнорируем, как и в Spider: // параллельный прогон по тому же корпусу не даёт пользы, // только путает счётчики execs/крашей. return m, nil } m.errMsg = "" return m, m.startCmd() } } var fieldCmd tea.Cmd switch m.focus { case 0: m.targetInput, fieldCmd = m.targetInput.Update(msg) case 1: m.argsInput, fieldCmd = m.argsInput.Update(msg) case 2: m.seedsInput, fieldCmd = m.seedsInput.Update(msg) case 3: m.workersInput, fieldCmd = m.workersInput.Update(msg) case 4: m.timeoutInput, fieldCmd = m.timeoutInput.Update(msg) } var vpCmd tea.Cmd m.viewport, vpCmd = m.viewport.Update(msg) return m, tea.Batch(fieldCmd, vpCmd) } // startCmd валидирует конфигурацию, грузит seed-файлы и запускает // fuzzer.Fuzzer. Директория для крашей создаётся автоматически под // ~/.burterm/fuzz-crashes// — не нужно отдельное поле // ввода ради этого. func (m fuzzerModel) startCmd() tea.Cmd { target := strings.TrimSpace(m.targetInput.Value()) argsRaw := strings.TrimSpace(m.argsInput.Value()) seedsRaw := strings.TrimSpace(m.seedsInput.Value()) useStdin := m.useStdin workersStr := m.workersInput.Value() timeoutStr := m.timeoutInput.Value() return func() tea.Msg { if target == "" { return fuzzErrMsg{fmt.Errorf("укажи путь к целевому исполняемому файлу")} } if seedsRaw == "" { return fuzzErrMsg{fmt.Errorf("укажи хотя бы один seed-файл")} } var args []string if argsRaw != "" { args = strings.Fields(argsRaw) } seedPaths := strings.Split(seedsRaw, ",") for i := range seedPaths { seedPaths[i] = strings.TrimSpace(seedPaths[i]) } seeds, err := fuzzer.LoadSeeds(seedPaths) if err != nil { return fuzzErrMsg{err} } workers, _ := strconv.Atoi(workersStr) if workers <= 0 { workers = 4 } timeoutSec, _ := strconv.Atoi(timeoutStr) if timeoutSec <= 0 { timeoutSec = 2 } crashDir, err := defaultFuzzCrashDir() if err != nil { return fuzzErrMsg{err} } f, err := fuzzer.New(fuzzer.Config{ Target: target, Args: args, UseStdin: useStdin, Timeout: time.Duration(timeoutSec) * time.Second, Workers: workers, CrashDir: crashDir, }, seeds) if err != nil { return fuzzErrMsg{err} } ctx, cancel := context.WithCancel(context.Background()) ch := f.Run(ctx) return fuzzStartedMsg{ch: ch, cancel: cancel, crashDir: crashDir} } } func defaultFuzzCrashDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err) } dir := filepath.Join(home, ".burterm", "fuzz-crashes", fmt.Sprintf("%d", time.Now().Unix())) if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("не удалось создать %s: %w", dir, err) } return dir, nil } func (m fuzzerModel) View() string { focusMark := func(i int) string { if m.focus == i { return "▸ " } return " " } var b strings.Builder b.WriteString(focusMark(0) + "Цель\n") b.WriteString(m.targetInput.View()) b.WriteString("\n") b.WriteString(focusMark(1) + fmt.Sprintf("Аргументы (ввод: %s, ctrl+i — переключить)\n", inputModeLabel(m.useStdin))) b.WriteString(m.argsInput.View()) b.WriteString("\n") b.WriteString(focusMark(2) + "Seed-файлы\n") b.WriteString(m.seedsInput.View()) b.WriteString("\n") b.WriteString(focusMark(3) + "Воркеры\n") b.WriteString(m.workersInput.View()) b.WriteString("\n") b.WriteString(focusMark(4) + "Таймаут, с\n") b.WriteString(m.timeoutInput.View()) b.WriteString("\n\n") status := "готов" if m.running { elapsed := time.Since(m.startedAt) rate := 0.0 if elapsed.Seconds() > 0 { rate = float64(m.execs) / elapsed.Seconds() } status = fmt.Sprintf("%s фаззинг… execs: %d (%.0f/с) · крашей: %d (esc — остановить)", m.spinner.View(), m.execs, rate, m.crashes) } b.WriteString(fmt.Sprintf("Статус: %s\n", status)) if m.crashDir != "" { b.WriteString("Краши сохраняются в: " + m.crashDir + "\n") } if m.errMsg != "" { b.WriteString("Ошибка: " + m.errMsg + "\n") } if m.statusMsg != "" { b.WriteString(m.statusMsg + "\n") } b.WriteString("\n" + focusMark(5) + m.viewport.View()) b.WriteString("\n\ntab — между полями · ctrl+r — запустить · ctrl+i — файл/stdin · ctrl+c — выход\n") b.WriteString("f1-f12, pgup — вкладки\n") return b.String() } func inputModeLabel(useStdin bool) string { if useStdin { return "stdin" } return "файл (@@)" }