335 lines
9.8 KiB
Go
335 lines
9.8 KiB
Go
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/<unix-время>/ — не нужно отдельное поле
|
||
// ввода ради этого.
|
||
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 "файл (@@)"
|
||
}
|