This commit is contained in:
Magnus Root 2026-09-14 10:55:07 +03:00
parent 9031af4311
commit 8b3832821e
52 changed files with 11255 additions and 0 deletions

197
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,197 @@
# burterm Architecture
Русская версия: [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
Design history, bugs found, and reliability caveats (Russian only): [NOTES.md](NOTES.md).
## Overview
burterm is a terminal tool for web pentesting, bug bounty, and CTF work:
a single static Go binary, 13 TUI tabs, and a headless CLI mode running on
the same engine. Builds without cgo.
```
cmd/burterm/ entry point: CLI flags or TUI launch
internal/
engine/ Intruder core: markers, attacks, filters
payload/ payload generators
cli/ flag and payload-spec parsing
proxy/ MITM proxy
spider/ crawler
sitemap/ scope rules, site tree
scanner/ active/passive vulnerability checks
fuzzer/ blackbox binary fuzzer
project/ session storage (SQLite+FTS5)
decoder/ comparer/ sequencer/ utilities
tui/ interface (bubbletea)
```
## General principles
- **Async I/O via `tea.Cmd`/`tea.Msg`.** The TUI follows the Elm
architecture: `Update(msg) (Model, Cmd)`. Any side effect (network,
disk, timer) is a `tea.Cmd` — a zero-argument function returning a
`tea.Msg`, run by the framework in its own goroutine. `Update` never
blocks on I/O.
- **Streaming operations go through channels.** Intruder attacks,
Scanner, Spider, Fuzzer all stream results via `<-chan T`. Shared
pattern: a producer goroutine writes via `select` with `<-ctx.Done()`;
the TUI reads via `waitForXxx(ch) tea.Cmd`, which reissues itself after
every message.
- **Values are snapshotted out of the model before a `tea.Cmd` runs in the
background** — the closure never holds a reference to a model that may
have already changed by the time the command executes.
## internal/engine — Intruder core
- `§markers§` in a raw HTTP request mark payload insertion points.
`ParseMarkers` extracts them into `[]InsertionPoint{Start, End}` (byte
offsets into the cleaned string) and returns marker-free text.
`InsertBeforeMarker` inserts arbitrary text right before the Nth
marker — used by recursion (see below).
- `BuildRequest` substitutes payloads and parses the result into
`*http.Request` via `http.ReadRequest`.
- Four attack modes: `RunSniper`, `RunBattering`, `RunPitchfork`,
`RunClusterBomb` — a shared worker pool reading jobs from a channel.
Pitchfork/ClusterBomb use `drainGenerator`, which reads a generator
fully into memory before starting.
- `EstimateTotal` computes the expected request count from generators'
`Len()` before the attack starts — powers the TUI's progress indicator.
- `ParseStatusFilter`/`ParseLengthFilter`/`ParseWordFilter`/
`ParseLineFilter` share a common operator syntax (`>N`, `<N`, `N-M`,
exact number) through one internal parser, `parseIntRangeFilter`.
- `New(cfg)` configures `http.Transport` with `MaxIdleConnsPerHost:
cfg.Concurrency` for connection reuse under concurrent requests to the
same host.
## internal/payload — payload generators
The `Generator` interface (`Next`/`Reset`/`Len`). Implementations:
`WordlistGenerator`, `NumericRangeGenerator`, `BruteForceGenerator`
(alphabet enumeration up to a given length), `ExtensionGenerator` (wraps
any generator, appending extensions to each word —
`spec|ext:.php,.html`). `presets.go` holds built-in presets (numbers,
letters, common usernames/passwords, SQLi/XSS/path-traversal probes).
`smart.go` is the heuristic that suggests a preset based on the parameter
name near a marker.
## internal/cli — CLI flags
`ParsePayloadSpec` parses strings like `wordlist:<path>` /
`range:<from>:<to>:<step>` / `preset:<name>` / `brute:<alphabet>:<min>:<max>`,
with an optional `|ext:...` suffix. Used by both the headless mode
(`cmd/burterm`) and the TUI — a single shared grammar.
## internal/proxy — MITM proxy
- `Server` is a forward proxy doing MITM via a self-owned CA (`ca.go`,
generated under `~/.burterm` on first run), a `CONNECT` tunnel with
`tls.Server`, and on-the-fly leaf certificate issuance keyed by SNI.
- Each transaction is an `Entry{Raw, StatusCode, Length, Headers,
ResponseBody, ...}` in a thread-safe `Store` (`sync.Mutex`,
subscription to new entries via a channel).
- `Server.SetScope(*sitemap.Scope)` under a `sync.RWMutex` — if set,
`capture`/`captureErr` skip storing entries outside scope. The proxy
still serves traffic regardless of scope.
- `session.go`: `ExtractSessionHeaders(entries, host)` pulls
`Cookie`/`Authorization` out of the history for a specific host
(parsed from `Entry.Raw`).
## internal/spider — crawler
`Spider.Crawl` does a BFS walk bounded by `scope *url.URL` and
`maxDepth`. `extractLinksAndForms` parses HTML, pulling out links and
form descriptions. GET forms (`Method=="GET"`) are automatically
converted into URLs with `test` placeholders per field and queued
(`buildGetFormURL`); POST forms are skipped. `SetHeaders` sets headers
applied to every crawl request — used to carry session
Cookie/Authorization from Proxy.
## internal/sitemap — scope and site tree
`Scope` is an ordered list of include/exclude regex rules
(`Add`/`RemoveAt`/`Matches`/`Rules`). `Tree`/`Node`/`Endpoint` is a site
path tree, built from a stream of URLs via `Build`, exportable to JSON.
## internal/scanner — vulnerability checks
- `Check` is the active-check interface (`Name`/`Probes`/`Analyze`),
substituting payloads into one insertion point via the same
`engine.BuildRequest` Intruder uses. Implementations: SQLi, XSS
(reflected, with a unique canary), SSTI, LFI, RCE (time-based), SSRF,
XXE.
- `Scanner.Run` returns a channel of `Event{Kind:
EventProgress|EventFinding}` — progress and findings in one stream.
- `CheckCORS` is a one-off check via the `Origin` header (doesn't fit
the `Check` interface since it doesn't operate through a marker).
- `DecodeJWT`/`AlgNoneVariant` parse a JWT and generate an unsigned
variant for bypass testing.
## internal/fuzzer — binary fuzzer
A blackbox mutation fuzzer for an arbitrary executable (CTF pwn).
`Config{Target, Args, UseStdin, Timeout, Workers, CrashDir}`. Mutations
(`mutate.go`): bit-flip, byte-flip, arithmetic, interesting values,
block delete/duplicate, splice, `havoc` (several mutations chained in
one pass). Crashes are detected via the process's exit signal
(`SIGSEGV`/`SIGABRT`/`SIGFPE`/`SIGILL`/`SIGBUS`) through
`syscall.WaitStatus`. `Run` returns a channel of `Event{Kind:
EventProgress|EventCrash}`; each worker is an independent goroutine with
its own `rand.Source`.
## internal/project — session storage
SQLite (`modernc.org/sqlite`, no cgo) with an FTS5 index in
external-content mode (text stored once in `requests`, `requests_fts`
holds only the inverted index), kept in sync via
`INSERT`/`UPDATE`/`DELETE` triggers. `Store.Open` sets
`db.SetMaxOpenConns(1)`. `ParseQuery` is a search mini-language
(`host:… method:… status:… color:… text`) — recognized `key:value`
tokens go into structured SQL, everything else into an FTS5 `MATCH`.
`SaveMeta`/`LoadMeta` store arbitrary key-value pairs (used for Target's
scope rules). `SaveLastPath`/`LoadLastPath` keep the path of the last
opened project outside the database itself
(`~/.burterm/last-project`).
## internal/decoder, comparer, sequencer
`decoder` — URL/Base64/HTML/Hex/MD5 codecs plus an auto-detect chain.
`comparer` — token-level LCS diff. `sequencer` — FIPS 140-2 statistical
tests (monobit/poker/runs/long-run) on 20000-bit blocks.
## internal/tui — interface
- `Model` (`model.go`) is the root model; the `active view` field
determines the visible tab. `Update` delegates unhandled messages to
the active tab.
- Each tab has its own `xxxModel` in a separate file
(`requestbuilder.go`, `results.go`, `proxyview.go`, `repeater.go`,
`spiderview.go`, `targetview.go`, `decoderview.go`, `comparerview.go`,
`sequencerview.go`, `dashboardview.go`, `scannerview.go`,
`projectview.go`, `fuzzerview.go`), with
`Update(tea.Msg) (xxxModel, tea.Cmd)` and `View() string` methods.
- `theme.go` — the color palette, `panel()` (a bordered box with a title,
via `lipgloss.RoundedBorder`), `renderTabs()` (the tab bar with the
active tab filled in), `renderLogo()`.
- `results.go` caches compiled filter predicates (`recompileFilters`),
accumulates filtered output into a `strings.Builder` incrementally
(`AddResult`), and renders into the `viewport` on a throttled 300ms
timer (`resultsTickCmd`), only for the attack currently visible.
- `attacks []*attackRun` is a list of independent concurrent attacks,
each with its own `id`/channel/`cancel`/`resultsModel`.
- `recursionMeta` holds the parameters for Intruder's auto-recursion
into discovered directories: request template, insertion point,
payload specs, depth, status predicate.
- Messages follow the `xxxStartedMsg`/`xxxResultMsg`/`xxxDoneMsg`/
`xxxErrMsg` pattern for every long-running operation.
## On-disk storage
Everything lives under `~/.burterm/`: `ca.pem`/`ca-key.pem` (the MITM
CA), `last-project` (path of the last project), `fuzz-crashes/<unix>/`
(fuzzer crash inputs), `sitemap-<unix>.json` (site map export).
## Running modes
The TUI (no arguments) and the headless CLI (`-request`/`-payload`/
`-mode` etc., see `internal/cli/flags.go`) both run on the same
`internal/engine` — CI/scripting scenarios don't duplicate attack logic.

193
ARCHITECTURE.ru.md Normal file
View file

@ -0,0 +1,193 @@
# Архитектура burterm
English version: [ARCHITECTURE.md](ARCHITECTURE.md).
История решений, найденные баги и оговорки по надёжности — в [NOTES.md](NOTES.md).
## Обзор
burterm — терминальный инструмент для веб-пентеста, bug bounty и CTF: один
статический Go-бинарник, 13 вкладок TUI и headless CLI-режим на том же
движке. Сборка без cgo.
```
cmd/burterm/ точка входа: CLI-флаги или запуск TUI
internal/
engine/ ядро Intruder: маркеры, атаки, фильтры
payload/ генераторы payload'ов
cli/ разбор флагов и payload-спецификаций
proxy/ MITM-прокси
spider/ краулер
sitemap/ scope-правила, дерево сайта
scanner/ активные/пассивные проверки уязвимостей
fuzzer/ blackbox-фаззер бинарников
project/ хранилище сессии (SQLite+FTS5)
decoder/ comparer/ sequencer/ утилиты
tui/ интерфейс (bubbletea)
```
## Общие принципы
- **Асинхронный I/O через `tea.Cmd`/`tea.Msg`.** TUI построен на архитектуре
Elm: `Update(msg) (Model, Cmd)`. Любой побочный эффект (сеть, диск,
таймер) — это `tea.Cmd`, функция без аргументов, возвращающая `tea.Msg`,
выполняемая фреймворком в отдельной горутине. `Update` никогда не
блокируется на I/O.
- **Потоковые операции — через каналы.** Intruder-атаки, Scanner, Spider,
Fuzzer стримят результаты через `<-chan T`. Общий паттерн: горутина-продюсер
пишет в канал через `select` с `<-ctx.Done()`; TUI читает через
`waitForXxx(ch) tea.Cmd`, переиздающий себя после каждого сообщения.
- **Значения снимаются с модели до ухода `tea.Cmd` в фон** — замыкание не
держит ссылку на модель, которая может измениться до того, как команда
выполнится.
## internal/engine — ядро Intruder
- `§маркеры§` в сыром HTTP-запросе отмечают точки вставки payload'а.
`ParseMarkers` вычленяет их в `[]InsertionPoint{Start, End}` (байтовые
смещения в очищенной строке) и возвращает текст без маркеров.
`InsertBeforeMarker` вставляет произвольный текст перед N-м маркером —
используется рекурсией (см. ниже).
- `BuildRequest` подставляет payload'ы и парсит результат в `*http.Request`
через `http.ReadRequest`.
- Четыре режима атаки: `RunSniper`, `RunBattering`, `RunPitchfork`,
`RunClusterBomb` — общий пул воркеров, читающих задания из канала.
Pitchfork/ClusterBomb используют `drainGenerator`, вычитывающий генератор
целиком в память перед стартом.
- `EstimateTotal` считает ожидаемое число запросов по `Len()` генераторов до
старта атаки — для индикации прогресса в TUI.
- `ParseStatusFilter`/`ParseLengthFilter`/`ParseWordFilter`/`ParseLineFilter`
— общий синтаксис (`>N`, `<N`, `N-M`, точное число) через один внутренний
парсер `parseIntRangeFilter`.
- `New(cfg)` настраивает `http.Transport` с `MaxIdleConnsPerHost:
cfg.Concurrency` для переиспользования соединений при конкурентных
запросах к одному хосту.
## internal/payload — генераторы payload'ов
Интерфейс `Generator` (`Next`/`Reset`/`Len`). Реализации:
`WordlistGenerator`, `NumericRangeGenerator`, `BruteForceGenerator`
(перебор алфавита заданной длины), `ExtensionGenerator` (оборачивает любой
генератор, добавляя расширения к каждому слову — `spec|ext:.php,.html`).
`presets.go` — набор готовых заготовок (числа, буквы, частые
имена/пароли, SQLi/XSS/path-traversal пробники). `smart.go` — эвристика
подсказки заготовки по имени параметра рядом с маркером.
## internal/cli — CLI-флаги
`ParsePayloadSpec` разбирает строки вида `wordlist:<путь>` /
`range:<from>:<to>:<step>` / `preset:<имя>` / `brute:<алфавит>:<min>:<max>`,
с необязательным суффиксом `|ext:...`. Используется и headless-режимом
(`cmd/burterm`), и TUI — единая грамматика.
## internal/proxy — MITM-прокси
- `Server` — форвардный прокси с MITM через собственный CA (`ca.go`,
генерируется в `~/.burterm` при первом запуске), `CONNECT`-туннель с
`tls.Server` и динамическим выпуском листовых сертификатов по SNI.
- Каждая транзакция — `Entry{Raw, StatusCode, Length, Headers,
ResponseBody, ...}` в потокобезопасном `Store` (`sync.Mutex`,
подписка на новые записи через канал).
- `Server.SetScope(*sitemap.Scope)` под `sync.RWMutex` — если задан,
`capture`/`captureErr` пропускают запись в `Store` для запросов вне
scope. Сам трафик прокси обслуживает независимо от scope.
- `session.go`: `ExtractSessionHeaders(entries, host)` вытаскивает
`Cookie`/`Authorization` из истории для конкретного хоста (парсит их из
`Entry.Raw`).
## internal/spider — краулер
`Spider.Crawl` — BFS-обход в границах `scope *url.URL` и `maxDepth`.
`extractLinksAndForms` парсит HTML, извлекает ссылки и описания форм.
GET-формы (`Method=="GET"`) автоматически конвертируются в URL с
заглушками `test` на каждое поле и добавляются в очередь обхода
(`buildGetFormURL`); POST-формы пропускаются. `SetHeaders` задаёт
заголовки для каждого запроса обхода — используется для передачи
сессионных Cookie/Authorization из Proxy.
## internal/sitemap — scope и дерево сайта
`Scope` — упорядоченный список include/exclude regex-правил
(`Add`/`RemoveAt`/`Matches`/`Rules`). `Tree`/`Node`/`Endpoint` — дерево
путей сайта, строится из потока URL через `Build`, экспортируется в JSON.
## internal/scanner — проверки уязвимостей
- `Check` — интерфейс активной проверки (`Name`/`Probes`/`Analyze`),
подставляет payload'ы в одну точку вставки через ту же
`engine.BuildRequest`, что и Intruder. Реализации: SQLi, XSS (reflected,
с уникальной канарейкой), SSTI, LFI, RCE (time-based), SSRF, XXE.
- `Scanner.Run` возвращает канал `Event{Kind: EventProgress|EventFinding}`
— прогресс и находки в одном потоке.
- `CheckCORS` — одноразовая проверка через заголовок `Origin` (не
вписывается в интерфейс `Check`, так как не работает через маркер).
- `DecodeJWT`/`AlgNoneVariant` — разбор JWT и генерация варианта без
подписи для проверки обхода.
## internal/fuzzer — фаззер бинарников
Blackbox-мутационный фаззер произвольного исполняемого файла (CTF pwn).
`Config{Target, Args, UseStdin, Timeout, Workers, CrashDir}`. Мутации
(`mutate.go`): bit-flip, byte-flip, arithmetic, interesting values, block
delete/duplicate, splice, `havoc` (комбинация нескольких мутаций за
проход). Краш определяется по сигналу завершения процесса
(`SIGSEGV`/`SIGABRT`/`SIGFPE`/`SIGILL`/`SIGBUS`) через
`syscall.WaitStatus`. `Run` возвращает канал `Event{Kind:
EventProgress|EventCrash}`, каждый воркер — независимая горутина со своим
`rand.Source`.
## internal/project — хранилище сессии
SQLite (`modernc.org/sqlite`, без cgo) с FTS5-индексом в режиме external
content (текст хранится один раз в `requests`, `requests_fts` держит
только инвертированный индекс), синхронизация через триггеры
`INSERT`/`UPDATE`/`DELETE`. `Store.Open` ставит `db.SetMaxOpenConns(1)`.
`ParseQuery` — мини-язык поиска (`host:… method:… status:… color:… текст`)
— известные `key:value`-токены уходят в структурированный SQL, остальное
— в FTS5 `MATCH`. `SaveMeta`/`LoadMeta` — произвольные key-value записи
(используется для scope-правил Target). `SaveLastPath`/`LoadLastPath`
путь последнего открытого проекта хранится вне самой БД
(`~/.burterm/last-project`).
## internal/decoder, comparer, sequencer
`decoder` — кодеки URL/Base64/HTML/Hex/MD5 и цепочка автоопределения.
`comparer` — токенный LCS-diff. `sequencer` — статистические тесты FIPS
140-2 (monobit/poker/runs/long-run) на 20000-битных блоках.
## internal/tui — интерфейс
- `Model` (`model.go`) — корневая модель, поле `active view` определяет
видимую вкладку. `Update` делегирует необработанные сообщения активной
вкладке.
- Каждая вкладка — своя `xxxModel` в отдельном файле
(`requestbuilder.go`, `results.go`, `proxyview.go`, `repeater.go`,
`spiderview.go`, `targetview.go`, `decoderview.go`, `comparerview.go`,
`sequencerview.go`, `dashboardview.go`, `scannerview.go`,
`projectview.go`, `fuzzerview.go`), с методами
`Update(tea.Msg) (xxxModel, tea.Cmd)` и `View() string`.
- `theme.go` — палитра, `panel()` (рамка с заголовком через
`lipgloss.RoundedBorder`), `renderTabs()` (таб-бар с заливкой активной
вкладки), `renderLogo()`.
- `results.go` — кэширует скомпилированные предикаты фильтров
(`recompileFilters`), копит отфильтрованный вывод в `strings.Builder`
инкрементально (`AddResult`), рендерит во `viewport` дросселированно по
таймеру `resultsTickCmd` (раз в 300мс), только для видимой атаки.
- `attacks []*attackRun` — список независимых параллельных атак, каждая
со своим `id`/каналом/`cancel`/`resultsModel`.
- `recursionMeta` — параметры авто-рекурсии Intruder в найденные
директории: шаблон запроса, точка вставки, спецификации payload'ов,
глубина, предикат статусов.
- Сообщения именуются по паттерну `xxxStartedMsg`/`xxxResultMsg`/
`xxxDoneMsg`/`xxxErrMsg` для каждой длительной операции.
## Хранение на диске
Всё под `~/.burterm/`: `ca.pem`/`ca-key.pem` (MITM CA), `last-project`
(путь последнего проекта), `fuzz-crashes/<unix>/` (крашащие вводы
фаззера), `sitemap-<unix>.json` (экспорт карты сайта).
## Режимы запуска
TUI (без аргументов) и headless CLI (`-request`/`-payload`/`-mode` и
т.д., см. `internal/cli/flags.go`) используют один и тот же
`internal/engine` — сценарии CI/скриптов не дублируют логику атаки.

236
NOTES.md Normal file
View file

@ -0,0 +1,236 @@
# Заметки по разработке burterm
Чистое описание архитектуры — в [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
Этот файл — то, что не годится в ARCHITECTURE, потому что описывает не
«как устроено», а «почему так вышло» и «на что обратить внимание». Весь
код писался в среде без сети и без установленного Go — ни разу не
проходил через компилятор.
## Реальные баги, найденные и исправленные по ходу разработки
Не гипотетические — конкретные, с объяснением, как нашлись.
- **UTF-8 в `§маркерах§`.** Маркер `§` (U+00A7) — два байта в UTF-8
(`0xC2 0xA7`). Самая ранняя версия `ParseMarkers` сравнивала байты
напрямую и детектила маркер только по второму байту, оставляя первый
(`0xC2`) мусором в очищенной строке. Баг жил с первого черновика,
найден при разработке `DetectParamName` (умные заготовки payload'ов).
Исправлено: сканирование через `range` по рунам.
- **O(N²) в Results.** `resultsModel` на каждый входящий результат атаки
заново парсил текст всех полей фильтра и пересобирал весь список
отфильтрованных строк через `strings.Join`. На словаре в миллион строк
это останавливало TUI намертво. Исправлено тремя механизмами:
кэшированные предикаты (парсятся один раз при изменении текста поля, а
не на каждый результат), инкрементальный `strings.Builder` вместо
пересборки списка, дросселированный рендер во `viewport` по таймеру
раз в 300мс — и только для той атаки, что видна на экране.
- **`MaxIdleConnsPerHost`баг с самого начала, не только для больших
словарей.** Голый `http.Transport` даёт дефолт 2 соединения на хост.
При `Concurrency > 2` (а дефолт формы — 10) большинство воркеров
закрывало и переоткрывало TCP/TLS-соединение на каждый запрос вместо
переиспользования из пула. Исправлено в `engine.New` и в
`spider.New` (там клиент был вообще голый — Transport==nil, то есть
неявно общий пакетный `http.DefaultTransport`).
- **Прокси не читал тело ответа.** Изначальная версия сохраняла только
заголовки/статус — без тела полнотекстовый поиск по Project был бы
пустым обещанием. Исправлено: `io.ReadAll` полного тела, капированная
копия (8КБ) для хранения, `resp.Body` восстанавливается через
`io.NopCloser` перед `resp.Write()` (тело можно прочитать только один
раз).
- **Утечка горутин при параллельных атаках.** Изначально `Model` хранила
один `resultsCh`/`cancel` на всё приложение — вторая атака без
дожидания первой тихо переключала отслеживание, а горутины первой
оставались писать в канал, который больше никто не читает
(небуферизованный канал — блокировка навечно). Исправлено переходом
на `[]*attackRun` (независимые атаки) и на `select` с `ctx.Done()` при
записи в `out` внутри `engine.send()`.
- **`proxyModel.SetSize` вообще не резервировал место под собственные
заголовок/футер.** `View()` всегда рисует "Перехвачено: N" + пустую
строку сверху и пустую строку + строку подсказки снизу — 4 строки
сверх `viewport`, а `SetSize` отдавал `viewport.Height` всю
переданную высоту без остатка. Из-за этого вкладка Proxy стабильно
вылезала за экран независимо от того, сколько высоты ей давал
`model.go` — сам бюджет внутри файла никогда не учитывал собственный
хром. Найдено построчным разбором `View()`, а не подбором чисел
снаружи. Исправлено вычитанием фиксированных 4 строк внутри
`SetSize`.
- **`comparerModel`: `SetSize` считал раскладку A/B side-by-side (делит
ширину пополам через `halfWidth`), а `View()` рисовал их друг под
другом.** Из-за этого реальная высота была примерно вдвое больше
того, что закладывал бюджет `diff.Height` — вкладка Comparer
вылезала за экран независимо от общего бюджета высоты. Исправлено не
подгонкой чисел, а починкой самого рассинхрона: `View()` теперь
реально кладёт A и B рядом через `lipgloss.JoinHorizontal`, как и
предполагала раскладка ширины с самого начала.
- **Футер Dashboard врал про `pgup`/`pgdn`.** Подсказка гласила
"pgup/pgdn — прокрутка", хотя после того как эти клавиши стали
перехватываться глобально под переключение вкладок (см. раздел
решений ниже), до `viewport` они больше не доходят и ничего не
прокручивают. Текст поправлен на "↑/↓ — прокрутка".
- **Бюджет высоты был неверен на 10 из 12 вкладок одновременно — и по
двум независимым причинам.** Первая: строки-подсказки в футере
доходили до 200+ символов (`targetview.go` — 425 символов исходно с
учётом экранирования, `repeater.go` — 219) — терминал переносил их
сам, а высота этого переноса нигде не учитывалась. Исправлено
разбиением каждого длинного футера на гарантированные 2 (иногда 3)
строки: специфичные подсказки вкладки отдельно, общая навигация
`f1-f12, pgup — вкладки` отдельно —
так поместится в одну физическую строку почти на любом терминале, а
не будет угадывания, перенесётся или нет. Вторая причина — независимая
от первой: у нескольких вкладок константа вычитания была просто
арифметически неверна **с самого начала**, до всей этой истории с
футерами (Fuzzer: `-12` вместо нужных `-20`, Repeater: `-2` вместо
`-8`, Decoder/Sequencer: `-4` вместо `-8`/`-6`, Spider/Project:
`-6`/`-8` вместо `-11`). Обе причины нашлись только построчным
подсчётом каждого `View()` — при этом подсчёте я сам дважды ошибся
на лету (Decoder сначала посчитал 5 вместо верных 8) и пересчитывал
заново. Итоговые числа для всех 12 файлов сведены в одну таблицу для
сверки:
```
comparerview.go: diff.Height = height - inputHeight - 6
dashboardview.go: vh = height - 3
decoderview.go: output.Height = height - inHeight - 8
fuzzerview.go: vh = height - 20 (минимум для "чистого" состояния)
projectview.go: vh = height - 11
proxyview.go: vh = height - 6 (плюс ещё -5 снаружи, в model.go, под "Прокси:...CA:...")
repeater.go: response.Height = height - reqHeight - 8
results.go: vh = height - 11 (плюс ещё -6 снаружи, в model.go, под заголовок атаки)
scannerview.go: vh = height - taHeight - 15 - len(scanner.AllCheckNames())
sequencerview.go: output.Height = height - inHeight - 6
spiderview.go: vh = height - 11
targetview.go: vh = height - 13
```
Request Builder сюда не входит — он не считает высоту `viewport` по
фиксированному бюджету (сайзинг пропорциональный, `height/3`), так
что этот класс бага его не касается, хотя длинный футер разбил и там
для единообразия.
- **Comparer вылезал за экран ещё дважды после починки JoinHorizontal**
(сначала `-5`, потом `-6`оба раза построчный подсчёт по коду
казался верным, но на практике не сходилось). Рабочая гипотеза:
`textarea.View()` (компонент bubbles), возможно, рендерит **не ровно**
столько строк, сколько задано через `SetHeight` — то есть у неё есть
какой-то собственный оверхед (рамка/индикатор/что-то ещё), которого
нет в исходниках, доступных мне без реального терминала. Это касается
не только Comparer — `Decoder`, `Repeater`, `Scanner`, `Request
Builder` тоже используют `textarea` и МОГЛИ пострадать той же
проблемой, просто не в такой явной форме (или ещё не протестированы
так тщательно). Для Comparer вместо третьей попытки точного подсчёта
поставлен солидный запас (`-12` вместо расчётных `6`, `inputHeight`
урезан до `height/4`) — если и остальные текстовые вкладки продолжат
вылезать за экран после всех починок из записи выше, это будет
подтверждением гипотезы, и той же логике (запас вместо точного числа)
нужно последовать и там.
## Непроверенные места (порядок — от вероятных проблем к маловероятным)
1. **`go.mod`: `modernc.org/sqlite v1.34.4`** — версия по памяти,
`go mod tidy` может подтянуть другую, `go.sum` пересоберётся с нуля.
2. **bubbles/bubbletea API**`Focus()` у `textinput`/`textarea` в
некоторых версиях возвращает `tea.Cmd`, в некоторых — нет; сигнатуры
`spinner.Tick` могли измениться между версиями.
3. **`exitErr.Sys().(syscall.WaitStatus)`** в `internal/fuzzer/fuzzer.go`
— platform-specific type assertion, работает только на Unix.
4. **Гонки данных, не проверенные race detector'ом.** Весь проект
писался с оглядкой на конкурентный доступ (`sync.RWMutex` в
`proxy.Server`, независимые снапшоты `sitemap.Scope`, `atomic` в
счётчиках фаззера), но ни разу не прогонялся под `go test -race`.
5. **`pgup`** (вкладка Fuzzer, единственная за пределами F1-F12 после
выделения Steganography/аудио в отдельный проект stegterm) —
четвёртая попытка навигации на вкладку, не влезающую в F1-F12
(история первых трёх — в разделе решений ниже). PageUp кодируется
терминалом так же атомарно, как F-клавиши (в отличие от Alt+буква,
которая на практике расщепляется на ESC+символ) — по конструкции
должен быть надёжнее, но конкретно в связке с Zellij ещё не
подтверждён.
## Сознательно отложенные и оставленные как есть решения
- **Навигация на Fuzzer — четыре попытки подряд, есть разобранная
причина третьего провала.** У физической клавиатуры нет F13/F14, а
13 вкладок в F1-F12 не помещаются (изначально этой проблеме были
подвержены сразу две вкладки — Fuzzer и Steganography, — но
Steganography с тех пор выделена в отдельный проект stegterm, и
сейчас в лишней клавише нуждается только Fuzzer). История:
1. `f13`/`f14` — не существуют физически, отпало сразу.
2. `shift+f1` — по историческому терминальному соглашению это и есть
F13 (`kf13` в terminfo), но подтверждённо не сработало в связке с
Zellij.
3. `alt+f` — подтверждённо не сработало, и в этот раз причина
найдена: терминал расщепляет `Alt+буква` на два отдельных байта
(`ESC`, затем сама буква) вместо единой комбинации. Если между
ними случается задержка (обычное дело через мультиплексор/SSH),
bubbletea не успевает склеить их обратно и видит одинокую букву —
а раз в фокусе текстовое поле (что почти всегда так по
умолчанию), эта буква просто печатается как обычный ввод, а не
распознаётся как комбинация. F-клавиши от этой болезни не страдают:
кодируются терминалом атомарно, не могут расщепиться в печатный
символ.
4. `pgup` — та же атомарная категория кодирования, что и у F-клавиш,
поэтому по конструкции не должен страдать той же болезнью, что
Alt-комбинации. Компромисс: `viewport` (Results, Proxy, Scanner и
т.д.) по умолчанию тоже вешает постраничную прокрутку на PageUp —
раз эта клавиша перехватывается глобально на уровне корневой
модели раньше, чем дойдёт до конкретной вкладки, постраничная
прокрутка пропадает везде, остаются только стрелки (построчно).
Осознанный выбор: надёжный доступ к вкладке важнее удобства
постраничной прокрутки. Не проверено на практике конкретно в
связке с Zellij.
- **Единая рамка вокруг тела каждой вкладки — откачена.** Была попытка
обернуть body любой вкладки (кроме Dashboard) в `panel()` для
визуальной консистентности со всем приложением. Дважды подряд не
смог вслепую подобрать для неё правильный бюджет высоты: сначала
меню обрезалось снизу на нескольких вкладках, потом (после первой
попытки исправить) сама рамка вылезала за край экрана на вкладках с
длинным содержимым (Comparer, Scanner). Без реального терминала под
рукой это угадывание, а не расчёт — после второго провала откатил
саму обёртку, а не в третий раз стал подбирать числа. Остались
только подтверждённо рабочие вещи: Dashboard со своими собственными
панелями и стилизованный таб-бар. Если понадобится вернуться к идее
единой рамки на каждой вкладке — делать это нужно с реальным
терминалом под рукой для итеративной проверки, не вслепую.
- **Плагины.** Обсуждалась возможность подключения внешних модулей.
Решение: не строить сейчас, направление на будущее — подпроцессы поверх
JSON Lines по stdin/stdout (не Go `plugin`-пакет — хрупкий, требует
точного совпадения версии Go; не встроенный Lua — новая непроверяемая
зависимость с тем же риском, что и sqlite). Подпроцесс живёт весь
прогон (атаку/скан), не спавнится на каждый вызов. Три типа плагинов
на будущее: `payload-generator`, `scanner-check`, `decoder`. В коде не
реализовано ничего.
- **Побочный эффект рекурсии Intruder.** У `Model` изначально было
заложено поведение «после старта атаки переключиться на Results» —
рекурсия тоже шлёт `attackStartedMsg` на каждую находку, значит при
активной рекурсии с несколькими совпадениями пользователя может
несколько раз подряд дёрнуть на вкладку Results. Решение неочевидно
(нужно различать «юзер нажал ctrl+r» от «рекурсия сама что-то
нашла»), оставлено как есть.
- **`drainGenerator` для Pitchfork/ClusterBomb** вычитывает генератор
целиком в память перед стартом. Для словарей на миллионы строк — разовая
пауза и всплеск памяти перед первым запросом. Не исправлено: риск
сломать корректность одометра выше выигрыша.
- **Только WAV, не MP3/FLAC.** Сознательный выбор, не недоделка: сжатие с
потерями убивает мелкую структуру спектра, в которую в CTF прячут
сообщения — задачи почти всегда именно в PCM WAV.
- **Поля "второе изображение"/"длина LSB" видны и в аудио-режиме
вкладки Steganography**, хотя там ничего не делают. Условный layout
ради их сокрытия усложнил бы `View()` заметнее, чем стоит того.
- **ASCII-логотип не в блочном стиле** (как у референсных TUI-инструментов
вроде Burp) — сознательно: точная посимвольная геометрия блочных букв
без реального терминала для проверки выравнивания легко разъезжается на
один символ. Вместо этого — разрядка текста акцентным цветом.
- **Заголовки панелей — не врезаны в саму рамку** (`┌─ TITLE ─┐`), а
первая строка жирным текстом внутри неё — по той же причине: точная
ширина без права на ошибку.
## Порядок отладки при первой сборке
1. `go mod tidy` — разберётся с зависимостями и `go.sum`.
2. `go build ./...` — почти наверняка всплывут точечные несовпадения
сигнатур bubbles/bubbletea. Правятся по месту, по тексту ошибки.
3. `go vet ./...` — статический анализ.
4. `go run -race ./cmd/burterm` — особенно при активном Proxy +
одновременной смене scope-правил.
5. Дальше — по вкладкам, от простых к сложным: Decoder/Comparer/Sequencer
(чистая логика, без сети) → Repeater (один запрос) → Intruder на
маленьком словаре → Proxy → Scanner/Fuzzer последними — там
наибольшая оставшаяся неопределённость.

87
README.md Normal file
View file

@ -0,0 +1,87 @@
# burterm
A terminal-based tool for web pentesting, bug bounty, and CTF work. A single
static Go binary, 13 TUI tabs (Dashboard, Request Builder, Results, Proxy,
Repeater, Spider, Target/Site Map, Decoder, Comparer, Sequencer, Scanner,
Project, Fuzzer), plus a headless CLI mode for scripts and CI.
- Detailed architecture: [ARCHITECTURE.md](ARCHITECTURE.md)
- Design history, bugs found, reliability caveats (Russian only): [NOTES.md](NOTES.md)
- Русская версия: [README.ru.md](README.ru.md)
Steganography/audio forensics (image bit-plane analysis, spectrograms,
SSTV decoding) used to live here as a 14th tab — split out into its own
project, [stegterm](https://github.com/r3g1tpr0cs/stegterm), since it has
nothing to do with web pentesting.
## Features
- **Proxy** — HTTP/HTTPS interception via a self-signed MITM CA, skips
out-of-scope traffic from history
- **Repeater** — manual request send/edit, diff against last sent version (ctrl+d)
- **Intruder** — Sniper/Battering Ram/Pitchfork/Cluster Bomb, smart payload
presets, recursion into discovered directories (ffuf's `-recursion`
equivalent), extension lists (`|ext:.php,.html`), status/length/word/line/grep filters
- **Spider** — crawler with GET-form auto-submission and automatic session
Cookie/Authorization pickup from Proxy history
- **Scanner** — SQLi/XSS/SSTI/LFI/RCE/SSRF/XXE/CORS/JWT, selectable checks
- **Project** — session persistence in SQLite with full-text search (FTS5),
filters by host/method/status/length/color label
- **Fuzzer** — blackbox mutation fuzzer for arbitrary binaries (CTF pwn),
crash detection via signal
- **Dashboard** — live metrics of the process itself (memory, goroutines,
RPS), laid out as bordered panels
## Building
```sh
go mod tidy # pulls dependencies, including modernc.org/sqlite
go build -o burterm ./cmd/burterm
```
Requires Go 1.23+. Builds without cgo.
**The code has never once gone through a compiler** — it was written in a
sandboxed environment with no network access and no Go toolchain
installed. Specific areas of uncertainty and the recommended order for
the first build are in [NOTES.md](NOTES.md) (Russian only).
## Running
No arguments — interactive TUI:
```sh
./burterm
```
With arguments — headless CLI (for scripts/CI):
```sh
./burterm -request req.txt -payload wordlist:common.txt -mode sniper
```
`req.txt` is a raw HTTP request with `§markers§` at insertion points:
```
GET /api/user?id=§1§ HTTP/1.1
Host: example.com
```
## Repository layout
```
cmd/burterm/ entry point (CLI + TUI launch)
internal/
engine/ Intruder core: markers, attack generators, filters
payload/ payload generators (wordlist/range/brute/preset/ext)
cli/ flag and payload-spec parsing
proxy/ MITM proxy, CA, scope filter, session auto-detection
spider/ crawler
sitemap/ scope rules, site map tree
scanner/ active/passive vulnerability checks
fuzzer/ blackbox binary fuzzer
project/ SQLite+FTS5 session storage
decoder/ comparer/ sequencer/ utilities
tui/ the whole interface (bubbletea)
```

86
README.ru.md Normal file
View file

@ -0,0 +1,86 @@
# burterm
Терминальный инструмент для веб-пентеста, bug bounty и CTF. Один статический
Go-бинарник, 13 вкладок TUI (Dashboard, Request Builder, Results, Proxy,
Repeater, Spider, Target/Site Map, Decoder, Comparer, Sequencer, Scanner,
Project, Fuzzer) плюс headless CLI-режим для скриптов и CI.
- Подробное описание архитектуры: [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md)
- История решений, найденные баги, оговорки по надёжности: [NOTES.md](NOTES.md)
- English version: [README.md](README.md)
Стеганография/аудио-форензика (анализ битовых плоскостей изображений,
спектрограммы, декодирование SSTV) раньше жили здесь 14-й вкладкой —
выделены в отдельный проект,
[stegterm](https://github.com/r3g1tpr0cs/stegterm), поскольку не имеют
отношения к веб-пентесту.
## Возможности
- **Proxy** — перехват HTTP/HTTPS через собственный MITM CA, скип трафика вне scope
- **Repeater** — ручная отправка/правка запроса, diff с последней отправкой (ctrl+d)
- **Intruder** — Sniper/Battering Ram/Pitchfork/Cluster Bomb, умные заготовки
payload'ов, рекурсия в найденные директории (аналог `-recursion` у ffuf),
расширения (`|ext:.php,.html`), фильтры по статусу/длине/словам/строкам/grep
- **Spider** — краулер с авто-сабмитом GET-форм и авто-подхватом сессионных
Cookie/Authorization из истории Proxy
- **Scanner** — SQLi/XSS/SSTI/LFI/RCE/SSRF/XXE/CORS/JWT, выбор конкретных проверок
- **Project** — сохранение сессии в SQLite с полнотекстовым поиском (FTS5),
фильтры по host/method/status/длине/цветовой метке
- **Fuzzer** — blackbox-мутационный фаззер произвольных бинарников (CTF pwn),
детект краша по сигналу
- **Dashboard** — живые метрики самого процесса (память, горутины, RPS),
оформлены как панели с рамками
## Сборка
```sh
go mod tidy # подтянет зависимости, включая modernc.org/sqlite
go build -o burterm ./cmd/burterm
```
Требуется Go 1.23+. Сборка без cgo.
**Код ни разу не проходил через компилятор** — писался в среде без сети и
без установленного Go. Список конкретных мест с неопределённостью и
порядок действий на первую сборку — в [NOTES.md](NOTES.md).
## Запуск
Без аргументов — интерактивный TUI:
```sh
./burterm
```
С аргументами — headless CLI (для скриптов/CI):
```sh
./burterm -request req.txt -payload wordlist:common.txt -mode sniper
```
`req.txt` — сырой HTTP-запрос с `§маркерами§` точек вставки:
```
GET /api/user?id=§1§ HTTP/1.1
Host: example.com
```
## Структура репозитория
```
cmd/burterm/ точка входа (CLI + запуск TUI)
internal/
engine/ ядро Intruder: маркеры, генераторы атак, фильтры
payload/ генераторы payload'ов (wordlist/range/brute/preset/ext)
cli/ разбор флагов и payload-спецификаций
proxy/ MITM-прокси, CA, scope-фильтр, авто-обнаружение сессий
spider/ краулер
sitemap/ scope-правила, дерево карты сайта
scanner/ активные/пассивные проверки уязвимостей
fuzzer/ blackbox-фаззер бинарников
project/ SQLite+FTS5 хранилище сессии
decoder/ comparer/ sequencer/ утилиты
tui/ весь интерфейс (bubbletea)
```

244
cmd/burterm/main.go Normal file
View file

@ -0,0 +1,244 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/url"
"os"
"os/signal"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/cli"
"github.com/r3g1tpr0cs/burterm/internal/engine"
"github.com/r3g1tpr0cs/burterm/internal/project"
"github.com/r3g1tpr0cs/burterm/internal/proxy"
"github.com/r3g1tpr0cs/burterm/internal/tui"
)
// defaultProxyAddr — адрес, на который в браузере/системе нужно
// настроить HTTP(S)-прокси, чтобы burterm начал перехватывать трафик.
const defaultProxyAddr = "127.0.0.1:8080"
func main() {
// -h/--help перехватывается ДО диспетчеризации TUI/CLI — иначе он бы
// просто попал в runCLI как обычный флаг, и пользователь увидел бы
// список флагов вперемешку с "ошибка: flag: help requested" и кодом
// выхода 1, как будто что-то сломалось, хотя это штатный запрос
// справки. Заодно единственное место, где объясняется, что вообще
// есть два режима запуска — TUI и headless CLI, а не только флаги.
if len(os.Args) > 1 && isHelpArg(os.Args[1]) {
printHelp()
return
}
// Без аргументов — интерактивный TUI. С аргументами (флаги -request и т.п.) —
// headless CLI-режим для скриптов и CI. Так один и тот же бинарник закрывает
// оба сценария без отдельного флага-переключателя.
if len(os.Args) > 1 {
if err := runCLI(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
// flag-пакет уже напечатал список флагов сам — здесь
// просто выходим чисто, без "ошибка:" и без кода 1.
return
}
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
os.Exit(1)
}
return
}
if err := runTUI(); err != nil {
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
os.Exit(1)
}
}
func isHelpArg(s string) bool {
return s == "-h" || s == "-help" || s == "--help"
}
// printHelp объясняет оба режима запуска и печатает список CLI-флагов
// (через cli.Usage — та же регистрация флагов, что и в реальном разборе,
// без дублирования списка вручную).
func printHelp() {
fmt.Print(`burterm терминальный инструмент для веб-пентеста, bug bounty и CTF.
РЕЖИМЫ ЗАПУСКА:
burterm интерактивный TUI, без аргументов
13 вкладок: f1-f12 + pgup,
справка по горячим клавишам внизу экрана
на каждой вкладке
burterm -request ... headless-атака одним прогоном (см. флаги ниже),
для скриптов и CI печатает результаты построчно
TUI поднимает локальный MITM-прокси на 127.0.0.1:8080 и автоматически
подхватывает последний открытый проект (~/.burterm/). Подробности по
вкладкам, синтаксису §маркеров§ и структуре проекта в README.md и
ARCHITECTURE.md репозитория.
ФЛАГИ HEADLESS CLI-РЕЖИМА:
`)
cli.Usage(os.Stdout)
}
// runTUI поднимает MITM-прокси (генерируя/загружая локальный CA) и
// запускает интерактивный интерфейс. Если прокси не смог занять порт,
// TUI всё равно стартует — вкладка Proxy останется пустой, но вкладку
// Request можно заполнить вручную, как раньше.
func runTUI() error {
caDir, err := proxy.CADir()
if err != nil {
return fmt.Errorf("директория CA: %w", err)
}
ca, err := proxy.LoadOrCreateCA(caDir)
if err != nil {
return fmt.Errorf("MITM CA: %w", err)
}
store := proxy.NewStore()
srv := proxy.New(defaultProxyAddr, ca, store)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe(ctx) }()
// Короткая пауза, чтобы поймать немедленную ошибку (например, порт
// уже занят) и предупредить пользователя до входа в alt-screen —
// после старта bubbletea это сообщение уже никто не увидит.
select {
case err := <-errCh:
fmt.Fprintf(os.Stderr, "прокси не запустился на %s: %v\n(можно продолжать и вводить запросы вручную на вкладке Request)\n\n", defaultProxyAddr, err)
case <-time.After(200 * time.Millisecond):
}
caPath := ca.CertPath(caDir)
// Автозагрузка последнего открытого проекта, если такой был —
// project.LoadLastPath хранит путь вне самой SQLite-БД (нужен раньше,
// чем что-либо открыто). Неудача здесь не фатальна для запуска TUI:
// просто вкладка Project стартует пустой, как при первом запуске.
var projectStore *project.Store
if lastPath, ok, lerr := project.LoadLastPath(); lerr == nil && ok {
if ps, operr := project.Open(lastPath); operr == nil {
projectStore = ps
} else {
fmt.Fprintf(os.Stderr, "не удалось автоматически открыть последний проект %s: %v\n", lastPath, operr)
}
}
p := tea.NewProgram(tui.New(store, defaultProxyAddr, caPath, srv, projectStore), tea.WithAltScreen())
_, err = p.Run()
return err
}
// runCLI собирает CLI-флаги, запрос и генераторы в один прогон атаки.
// Вынесен из main для того, чтобы возвращать error обычным способом
// вместо os.Exit по месту.
func runCLI(args []string) error {
flags, err := cli.Parse(args)
if err != nil {
return err
}
rawBytes, err := os.ReadFile(flags.RequestFile)
if err != nil {
return fmt.Errorf("не удалось прочитать файл запроса: %w", err)
}
clean, points, err := engine.ParseMarkers(string(rawBytes))
if err != nil {
return fmt.Errorf("не удалось разобрать маркеры §...§: %w", err)
}
if len(points) == 0 {
return fmt.Errorf("в запросе не найдено ни одного §маркера§ — нечего фаззить")
}
gens, err := flags.BuildGenerators()
if err != nil {
return err
}
mode, err := flags.AttackMode()
if err != nil {
return err
}
base := []string(flags.Base)
if len(base) == 0 {
base = make([]string, len(points))
}
eng := engine.New(engine.Config{
Concurrency: flags.Concurrency,
RateLimit: flags.RateLimit,
Timeout: flags.Timeout,
InsecureSkipVerify: flags.Insecure,
ForceScheme: flags.Scheme,
})
// Ctrl+C прерывает атаку через контекст, а не убивает процесс мгновенно —
// воркеры доотправляют текущие запросы и корректно закрывают каналы.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
results, err := eng.Run(ctx, mode, clean, points, gens, base)
if err != nil {
return fmt.Errorf("не удалось запустить атаку: %w", err)
}
var store *project.Store
if flags.Project != "" {
store, err = project.Open(flags.Project)
if err != nil {
return fmt.Errorf("не удалось открыть проект %s: %w", flags.Project, err)
}
defer store.Close()
fmt.Printf("Сохраняю результаты в проект: %s\n\n", flags.Project)
}
printResults(results, store)
return nil
}
// printResults выводит результаты атаки построчно по мере поступления
// из канала. Используется только в headless CLI-режиме — полноценная
// интерактивная таблица с прокруткой живёт в internal/tui. Если store
// не nil (флаг -project), каждый результат ещё и сохраняется в проект —
// та же база, что видит вкладка Project в TUI, так что результаты
// headless-прогона можно потом открыть и поискать интерактивно.
func printResults(results <-chan engine.Result, store *project.Store) {
fmt.Printf("%-6s %-8s %-10s %s\n", "СТАТУС", "ДЛИНА", "ВРЕМЯ", "PAYLOAD")
for r := range results {
if r.Err != nil {
fmt.Printf("ERR - - %v (payload: %v)\n", r.Err, r.Payload)
} else {
fmt.Printf("%-6d %-8d %-10s %v\n", r.StatusCode, r.Length, r.Duration.Round(time.Millisecond), r.Payload)
}
if store == nil {
continue
}
host := ""
if u, err := url.Parse(r.URL); err == nil {
host = u.Host
}
if _, err := store.Insert(project.Record{
Source: "intruder-cli",
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,
}); err != nil {
fmt.Fprintf(os.Stderr, "предупреждение: не удалось сохранить результат в проект: %v\n", err)
}
}
}

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module github.com/r3g1tpr0cs/burterm
go 1.23
require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.2.4
github.com/charmbracelet/lipgloss v1.0.0
golang.org/x/net v0.34.0
// modernc.org/sqlite драйвер SQLite на чистом Go, без cgo (важно:
// весь остальной проект собирается без единого cgo-пакета, и хотелось
// сохранить эту инвариантность ради простоты сборки на ALT Linux без
// gcc в цепочке). Версия указана по памяти я не могу скачать модуль
// в этом окружении (нет сети до proxy.golang.org), так что первый
// `go mod tidy` у тебя может подтянуть другую совместимую версию и
// перестроить go.sum с нуля это ожидаемо и нормально.
modernc.org/sqlite v1.34.4
)

273
internal/cli/flags.go Normal file
View file

@ -0,0 +1,273 @@
package cli
import (
"bufio"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/r3g1tpr0cs/burterm/internal/engine"
"github.com/r3g1tpr0cs/burterm/internal/payload"
)
// stringList — flag.Value, копящий повторяющиеся флаги в срез
// в порядке их указания в командной строке.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error { *s = append(*s, v); return nil }
// Flags — разобранные аргументы командной строки для одного запуска атаки.
type Flags struct {
RequestFile string
Mode string // "sniper" | "battering" | "clusterbomb"
Payloads stringList // спецификации вида "wordlist:<путь>" или "range:<from>:<to>:<step>", по одной на точку вставки
Base stringList // базовые значения для точек, не участвующих в переборе (режим sniper)
Concurrency int
RateLimit time.Duration
Timeout time.Duration
Insecure bool
Scheme string // "" (авто, дефолт https) | "http" | "https"
Project string // путь к файлу проекта (SQLite); пусто — не сохранять результаты
}
// newFlagSet регистрирует все флаги CLI-режима один раз — используется
// и Parse (реальный разбор), и Usage (печать справки для -h/--help),
// чтобы список флагов с их описаниями не дублировался в двух местах.
func newFlagSet() (*flag.FlagSet, *Flags) {
fs := flag.NewFlagSet("burterm", flag.ContinueOnError)
f := &Flags{}
fs.StringVar(&f.RequestFile, "request", "", "путь к файлу с сырым HTTP-запросом и §маркерами§ точек вставки (обязательно)")
fs.StringVar(&f.Mode, "mode", "sniper", "режим атаки: sniper | battering | pitchfork | clusterbomb")
fs.Var(&f.Payloads, "payload", "источник payload'ов; повторяй флаг в порядке точек вставки: wordlist:<путь> | range:<from>:<to>:<step> | preset:<имя> | brute:<алфавит>:<min>:<max> — к любому можно дописать |ext:.php,.html для перебора расширений")
fs.Var(&f.Base, "base", "базовое значение для точки вставки в режиме sniper, по одному флагу на точку")
fs.IntVar(&f.Concurrency, "concurrency", 10, "число параллельных воркеров")
fs.DurationVar(&f.RateLimit, "rate-limit", 0, "пауза между запросами на воркер, напр. 100ms")
fs.DurationVar(&f.Timeout, "timeout", 10*time.Second, "таймаут одного HTTP-запроса")
fs.BoolVar(&f.Insecure, "insecure", false, "не проверять TLS-сертификат сервера (self-signed стенды)")
fs.StringVar(&f.Scheme, "scheme", "", "принудительная схема: http | https (по умолчанию — как определит парсер запроса, обычно https)")
fs.StringVar(&f.Project, "project", "", "путь к файлу проекта (SQLite) — если указан, результаты атаки сохраняются туда же, что видит TUI на вкладке Project")
return fs, f
}
// Parse разбирает os.Args (без имени программы) в Flags и валидирует
// обязательные поля.
func Parse(args []string) (*Flags, error) {
fs, f := newFlagSet()
if err := fs.Parse(args); err != nil {
return nil, err
}
if f.RequestFile == "" {
return nil, fmt.Errorf("нужно указать -request <файл>")
}
if len(f.Payloads) == 0 {
return nil, fmt.Errorf("нужен хотя бы один -payload")
}
if f.Scheme != "" && f.Scheme != "http" && f.Scheme != "https" {
return nil, fmt.Errorf("-scheme должен быть http, https или пустым, получено %q", f.Scheme)
}
return f, nil
}
// Usage печатает список флагов headless CLI-режима в w — используется
// main.go для -h/--help, чтобы не дублировать регистрацию флагов.
func Usage(w io.Writer) {
fs, _ := newFlagSet()
fs.SetOutput(w)
fs.Usage()
}
// AttackMode транслирует строковое имя режима в engine.AttackMode.
func (f *Flags) AttackMode() (engine.AttackMode, error) {
switch f.Mode {
case "sniper":
return engine.ModeSniper, nil
case "battering":
return engine.ModeBattering, nil
case "pitchfork":
return engine.ModePitchfork, nil
case "clusterbomb":
return engine.ModeClusterBomb, nil
default:
return 0, fmt.Errorf("неизвестный режим %q (ожидался sniper, battering, pitchfork или clusterbomb)", f.Mode)
}
}
// BuildGenerators превращает спецификации --payload в конкретные
// payload.Generator, в том же порядке, в каком флаги были указаны
// (этот порядок соответствует порядку §маркеров§ в запросе).
func (f *Flags) BuildGenerators() ([]payload.Generator, error) {
gens := make([]payload.Generator, 0, len(f.Payloads))
for _, spec := range f.Payloads {
g, err := parsePayloadSpec(spec)
if err != nil {
return nil, fmt.Errorf("payload %q: %w", spec, err)
}
gens = append(gens, g)
}
return gens, nil
}
// ParsePayloadSpec — экспортированная версия parsePayloadSpec. Используется
// не только CLI-флагами, но и TUI, чтобы оба интерфейса разбирали строки
// вида "wordlist:<путь>" / "range:<from>:<to>:<step>" одинаково.
func ParsePayloadSpec(spec string) (payload.Generator, error) {
return parsePayloadSpec(spec)
}
// parsePayloadSpec сначала снимает необязательный суффикс "|ext:..."
// (аналог -e у ffuf — список расширений, дописываемых к каждому слову),
// затем разбирает оставшуюся часть как обычную спецификацию через
// parseBaseSpec и, если расширения были заданы, оборачивает результат
// в payload.ExtensionGenerator.
func parsePayloadSpec(spec string) (payload.Generator, error) {
base, extensions, err := splitExtSuffix(spec)
if err != nil {
return nil, err
}
gen, err := parseBaseSpec(base)
if err != nil {
return nil, err
}
if len(extensions) > 0 {
gen = payload.NewExtensionGenerator(gen, extensions)
}
return gen, nil
}
// splitExtSuffix отделяет "|ext:.php,.html,bak" от конца спецификации —
// суффикс может стоять у любого типа spec (wordlist/preset/range/brute).
// Ведущая точка у расширения необязательна во вводе ("bak" эквивалентно
// ".bak") — добавляется автоматически, чтобы не спотыкаться на забытой точке.
func splitExtSuffix(spec string) (base string, extensions []string, err error) {
idx := strings.LastIndex(spec, "|ext:")
if idx == -1 {
return spec, nil, nil
}
base = spec[:idx]
extPart := spec[idx+len("|ext:"):]
if extPart == "" {
return "", nil, fmt.Errorf("|ext: указан, но список расширений пуст")
}
for _, e := range strings.Split(extPart, ",") {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !strings.HasPrefix(e, ".") {
e = "." + e
}
extensions = append(extensions, e)
}
return base, extensions, nil
}
// parseBaseSpec разбирает одну спецификацию вида "тип:параметры" —
// без учёта возможного "|ext:" суффикса, который снимается выше.
func parseBaseSpec(spec string) (payload.Generator, error) {
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("ожидался формат тип:параметры, напр. wordlist:/путь/к/файлу")
}
kind, arg := parts[0], parts[1]
switch kind {
case "wordlist":
words, err := readLines(arg)
if err != nil {
return nil, err
}
if len(words) == 0 {
return nil, fmt.Errorf("файл словаря %q пуст", arg)
}
return payload.NewWordlistGenerator(words), nil
case "range":
nums := strings.Split(arg, ":")
if len(nums) != 3 {
return nil, fmt.Errorf("ожидался формат range:from:to:step")
}
from, err := strconv.Atoi(nums[0])
if err != nil {
return nil, fmt.Errorf("некорректный from: %w", err)
}
to, err := strconv.Atoi(nums[1])
if err != nil {
return nil, fmt.Errorf("некорректный to: %w", err)
}
step, err := strconv.Atoi(nums[2])
if err != nil {
return nil, fmt.Errorf("некорректный step: %w", err)
}
if step <= 0 {
return nil, fmt.Errorf("step должен быть положительным, получено %d", step)
}
return payload.NewNumericRangeGenerator(from, to, step), nil
case "preset":
g, err := payload.NewFromPreset(arg)
if err != nil {
return nil, err
}
return g, nil
case "brute":
// Формат: brute:<алфавит>:<min>:<max> — прямой перебор без
// готовой заготовки, для случаев, когда ни одна из встроенных
// не подходит (свой алфавит, другая длина и т.п.).
parts := strings.SplitN(arg, ":", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("ожидался формат brute:<алфавит>:<min>:<max>")
}
charset := parts[0]
if charset == "" {
return nil, fmt.Errorf("алфавит для brute не может быть пустым")
}
minLen, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("некорректный min: %w", err)
}
maxLen, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("некорректный max: %w", err)
}
if minLen < 1 || maxLen < minLen {
return nil, fmt.Errorf("ожидалось 1 <= min <= max, получено min=%d max=%d", minLen, maxLen)
}
return payload.NewBruteForceGenerator(charset, minLen, maxLen), nil
default:
return nil, fmt.Errorf("неизвестный тип payload'а %q (ожидался wordlist, range, preset или brute)", kind)
}
}
// readLines читает файл построчно, пропуская пустые строки
// и обрезая \r на случай CRLF-словарей.
func readLines(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("не удалось открыть файл со словарём: %w", err)
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
if line == "" {
continue
}
lines = append(lines, line)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("ошибка чтения словаря: %w", err)
}
return lines, nil
}

118
internal/comparer/diff.go Normal file
View file

@ -0,0 +1,118 @@
// Package comparer реализует построчно-словесное сравнение двух текстов
// (аналог вкладки Comparer в Burp) на основе классического LCS-алгоритма.
package comparer
import "regexp"
// tokenPattern разбивает текст на чередующиеся куски пробелов и
// непробельных последовательностей — так diff получается на уровне
// "слов" (примерно как режим "Words" в Burp Comparer), но при этом
// исходный текст полностью восстанавливается конкатенацией токенов,
// включая переносы строк и пробелы между ними.
var tokenPattern = regexp.MustCompile(`\s+|\S+`)
func tokenize(s string) []string {
return tokenPattern.FindAllString(s, -1)
}
// Op — тип операции над токеном при переходе от a к b.
type Op int
const (
OpEqual Op = iota
OpDelete
OpInsert
)
// Chunk — последовательность токенов с одной и той же операцией,
// склеенных в одну строку для компактного вывода.
type Chunk struct {
Op Op
Text string
}
// Diff сравнивает a и b на уровне токенов (слов и пробельных
// промежутков) через LCS и возвращает последовательность чанков:
// OpEqual — совпадающие куски, OpDelete — есть только в a,
// OpInsert — есть только в b.
//
// Сложность O(n·m) по числу токенов в a и b — для вставки HTTP-запросов
// (сотни-тысячи токенов) это доли миллисекунды; для вставки многомегабайтных
// текстов такой алгоритм не годится, но это осознанно не наш случай.
func Diff(a, b string) []Chunk {
ta := tokenize(a)
tb := tokenize(b)
lcs := lcsTable(ta, tb)
ops := backtrack(lcs, ta, tb, len(ta), len(tb))
return mergeChunks(ops)
}
type tokenOp struct {
op Op
text string
}
// lcsTable строит стандартную DP-таблицу длин наибольшей общей
// подпоследовательности для токенов ta и tb.
func lcsTable(ta, tb []string) [][]int {
n, m := len(ta), len(tb)
table := make([][]int, n+1)
for i := range table {
table[i] = make([]int, m+1)
}
for i := 1; i <= n; i++ {
for j := 1; j <= m; j++ {
if ta[i-1] == tb[j-1] {
table[i][j] = table[i-1][j-1] + 1
} else if table[i-1][j] >= table[i][j-1] {
table[i][j] = table[i-1][j]
} else {
table[i][j] = table[i][j-1]
}
}
}
return table
}
// backtrack восстанавливает последовательность операций из DP-таблицы,
// двигаясь от (n, m) к (0, 0), и разворачивает её в порядок a→b.
func backtrack(table [][]int, ta, tb []string, i, j int) []tokenOp {
var reversed []tokenOp
for i > 0 || j > 0 {
switch {
case i > 0 && j > 0 && ta[i-1] == tb[j-1]:
reversed = append(reversed, tokenOp{OpEqual, ta[i-1]})
i--
j--
case j > 0 && (i == 0 || table[i][j-1] >= table[i-1][j]):
reversed = append(reversed, tokenOp{OpInsert, tb[j-1]})
j--
default:
reversed = append(reversed, tokenOp{OpDelete, ta[i-1]})
i--
}
}
ops := make([]tokenOp, len(reversed))
for k, v := range reversed {
ops[len(reversed)-1-k] = v
}
return ops
}
// mergeChunks склеивает подряд идущие токены с одинаковой операцией
// в один Chunk — иначе на каждое слово приходился бы отдельный чанк,
// что нечитаемо в выводе.
func mergeChunks(ops []tokenOp) []Chunk {
var chunks []Chunk
for _, o := range ops {
if len(chunks) > 0 && chunks[len(chunks)-1].Op == o.op {
chunks[len(chunks)-1].Text += o.text
continue
}
chunks = append(chunks, Chunk{Op: o.op, Text: o.text})
}
return chunks
}

42
internal/config/config.go Normal file
View file

@ -0,0 +1,42 @@
package config
import "time"
// Config хранит настройки одного запуска burterm,
// собранные из CLI-флагов или конфиг-файла.
type Config struct {
// TargetURL — исходный URL или путь к файлу с raw HTTP-запросом.
TargetURL string
// RawRequestFile — путь к файлу с сырым HTTP-запросом и §маркерами§.
RawRequestFile string
// Concurrency — число параллельных воркеров в Intruder-движке.
Concurrency int
// RateLimit — пауза между запросами на один воркер (0 — без ограничения).
RateLimit time.Duration
// Timeout — таймаут одного HTTP-запроса.
Timeout time.Duration
// SpiderMaxDepth — максимальная глубина обхода для Spider.
SpiderMaxDepth int
// WordlistPath — путь к словарю payload'ов для Intruder.
WordlistPath string
// InsecureSkipVerify — отключить проверку TLS-сертификата
// (полезно для тестовых стендов с self-signed сертификатами).
InsecureSkipVerify bool
}
// Default возвращает конфигурацию с разумными значениями по умолчанию.
func Default() Config {
return Config{
Concurrency: 10,
RateLimit: 0,
Timeout: 10 * time.Second,
SpiderMaxDepth: 3,
}
}

223
internal/decoder/decoder.go Normal file
View file

@ -0,0 +1,223 @@
// Package decoder реализует набор кодировок/декодировок (аналог вкладки
// Decoder в Burp): URL, Base64, HTML-entities, Hex и однонаправленный MD5,
// плюс SmartDecode — эвристическая цепочка автоматического декодирования,
// подбирающая, какая кодировка применена, и снимающая её слой за слоем.
package decoder
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"fmt"
"html"
"net/url"
"strings"
)
// URLEncode кодирует строку как query-компонент URL (пробел → '+').
func URLEncode(s string) string {
return url.QueryEscape(s)
}
// URLDecode декодирует query-компонент URL.
func URLDecode(s string) (string, error) {
out, err := url.QueryUnescape(s)
if err != nil {
return "", fmt.Errorf("URL-декодирование: %w", err)
}
return out, nil
}
// Base64Encode кодирует в стандартный Base64 (с паддингом).
func Base64Encode(s string) string {
return base64StdEncode([]byte(s))
}
// Base64Decode декодирует Base64, принимая как стандартный, так и
// URL-safe алфавит и терпимо относясь к отсутствующему паддингу —
// на практике токены из веба встречаются в обоих вариантах.
func Base64Decode(s string) (string, error) {
b, err := base64FlexibleDecode(s)
if err != nil {
return "", fmt.Errorf("Base64-декодирование: %w", err)
}
return string(b), nil
}
// HTMLEncode экранирует HTML-спецсимволы (<, >, &, ", ').
func HTMLEncode(s string) string {
return html.EscapeString(s)
}
// HTMLDecode разворачивает HTML-сущности (&lt;, &amp;, &#39; и т.п.).
func HTMLDecode(s string) string {
return html.UnescapeString(s)
}
// HexEncode кодирует строку в шестнадцатеричное представление.
func HexEncode(s string) string {
return hex.EncodeToString([]byte(s))
}
// HexDecode декодирует шестнадцатеричную строку обратно в текст.
func HexDecode(s string) (string, error) {
b, err := hex.DecodeString(strings.TrimSpace(s))
if err != nil {
return "", fmt.Errorf("hex-декодирование: %w", err)
}
return string(b), nil
}
// base64StdEncode — тонкая обёртка над стандартной библиотекой,
// вынесенная в отдельную функцию ради симметрии с base64FlexibleDecode.
func base64StdEncode(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
// base64FlexibleDecode перебирает стандартный и URL-safe алфавиты,
// с паддингом и без — то есть все четыре комбинации, которые реально
// встречаются в вебе (JWT, например, использует RawURLEncoding).
func base64FlexibleDecode(s string) ([]byte, error) {
encodings := []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
}
var lastErr error
for _, enc := range encodings {
if b, err := enc.DecodeString(s); err == nil {
return b, nil
} else {
lastErr = err
}
}
return nil, lastErr
}
// MD5Hash — однонаправленное хэширование, декодирования не существует.
func MD5Hash(s string) string {
sum := md5.Sum([]byte(s))
return hex.EncodeToString(sum[:])
}
// Step — один шаг цепочки SmartDecode: какая кодировка была снята
// и что получилось после этого шага.
type Step struct {
Operation string
Result string
}
// SmartDecode пытается определить, чем закодирована строка, и снимает
// кодировки слой за слоем, пока распознаётся хотя бы одна из известных
// (URL, Base64, Hex, HTML) или не достигнут предел итераций. Это
// эвристика, а не гарантированно верное определение — при неоднозначных
// входах (например, строка, которая одновременно валидный Base64 и
// валидный hex) приоритет фиксирован в порядке проверки ниже.
func SmartDecode(s string) []Step {
const maxSteps = 8
var steps []Step
cur := s
for i := 0; i < maxSteps; i++ {
op, next, ok := detectAndDecodeOne(cur)
if !ok || next == cur {
break
}
steps = append(steps, Step{Operation: op, Result: next})
cur = next
}
return steps
}
// detectAndDecodeOne проверяет кодировки по очереди и применяет первую
// подошедшую. Порядок проверки важен: URL-encoding и HTML-entities имеют
// узнаваемые маркеры (%XX, &...;) и проверяются первыми как самые
// однозначные; Base64 и Hex — по составу алфавита, где Hex строже
// (только 0-9a-f), поэтому его проверяем раньше Base64.
func detectAndDecodeOne(s string) (op, result string, ok bool) {
trimmed := strings.TrimSpace(s)
if strings.Contains(s, "%") && looksURLEncoded(s) {
if out, err := URLDecode(s); err == nil && out != s {
return "URL-decode", out, true
}
}
if strings.Contains(s, "&") && strings.Contains(s, ";") {
if out := HTMLDecode(s); out != s {
return "HTML-decode", out, true
}
}
if looksHex(trimmed) {
if out, err := HexDecode(trimmed); err == nil && isPrintable(out) {
return "Hex-decode", out, true
}
}
if looksBase64(trimmed) {
if out, err := Base64Decode(trimmed); err == nil && isPrintable(out) {
return "Base64-decode", out, true
}
}
return "", "", false
}
func looksURLEncoded(s string) bool {
for i := 0; i < len(s)-2; i++ {
if s[i] == '%' && isHexByte(s[i+1]) && isHexByte(s[i+2]) {
return true
}
}
return false
}
func isHexByte(c byte) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func looksHex(s string) bool {
if s == "" || len(s)%2 != 0 {
return false
}
for i := 0; i < len(s); i++ {
if !isHexByte(s[i]) {
return false
}
}
return true
}
func looksBase64(s string) bool {
if s == "" || len(s)%4 != 0 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '+', c == '/', c == '-', c == '_', c == '=':
default:
return false
}
}
return true
}
// isPrintable — грубый фильтр "похоже на текст, а не на мусор", чтобы
// SmartDecode не радостно декодировал случайные бинарные данные,
// формально прошедшие проверку алфавита Base64/Hex, но не являющиеся
// осмысленной строкой на выходе.
func isPrintable(s string) bool {
if s == "" {
return false
}
printable := 0
for _, r := range s {
if r == '\n' || r == '\r' || r == '\t' || (r >= 0x20 && r < 0x7f) {
printable++
}
}
return float64(printable)/float64(len([]rune(s))) > 0.85
}

553
internal/engine/engine.go Normal file
View file

@ -0,0 +1,553 @@
package engine
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/r3g1tpr0cs/burterm/internal/payload"
)
// AttackMode задаёт способ комбинирования генераторов и точек вставки,
// по аналогии с режимами Burp Intruder.
type AttackMode int
const (
// ModeSniper — один генератор, payload'ы по очереди проходят через
// каждую точку вставки (остальные точки остаются пустыми/базовыми).
ModeSniper AttackMode = iota
// ModeBattering — один генератор, payload вставляется во все точки
// одновременно (одно и то же значение везде).
ModeBattering
// ModePitchfork — по генератору на каждую точку вставки, но идут они
// в ногу по индексу (payloads[i] из каждого генератора вместе
// формируют один запрос), а не декартовым произведением. Останавливается
// на самом коротком генераторе — как в Burp: если один список короче
// остальных, лишние значения из более длинных просто не используются.
ModePitchfork
// ModeClusterBomb — разные генераторы на разные позиции,
// декартово произведение всех комбинаций.
ModeClusterBomb
)
// Result — результат одного запроса атаки.
type Result struct {
Payload []string
// URL и Method — куда именно ушёл этот конкретный запрос атаки.
// Заполняются в send() из уже собранного *http.Request — нужны,
// в частности, чтобы результаты Intruder-атаки можно было сохранить
// в project.Store (там для каждой записи нужны host/method/url,
// а Result сам по себе их не содержал до этого момента).
URL string
Method string
StatusCode int
// Length — фактическое количество прочитанных байт тела ответа,
// а не значение заголовка Content-Length (оно часто равно -1 при
// chunked encoding и вводило бы в заблуждение как база для фильтра
// по длине).
Length int64
// Body — тело ответа, обрезанное до maxBodyCapture байт. Полное
// тело не хранится намеренно: при тысячах запросов в атаке это
// быстро съело бы память; для grep-фильтра обрезанного фрагмента
// обычно достаточно, чтобы поймать характерную строку в ответе.
Body string
// Headers — заголовки ответа (используются, в частности, для
// цветовой маркировки по Content-Type в TUI).
Headers http.Header
Duration time.Duration
Err error
}
// maxBodyCapture — сколько байт тела ответа сохраняется в Result.Body.
const maxBodyCapture = 4096
// Config описывает параметры выполнения атаки.
type Config struct {
Concurrency int
RateLimit time.Duration // пауза между запросами на воркер
Timeout time.Duration
InsecureSkipVerify bool
// ForceScheme принудительно проставляет схему ("http" или "https")
// для каждого запроса атаки, перекрывая дефолт из BuildRequest
// (который всегда ставит https, если в запросе нет абсолютного URI).
// Пустая строка — оставить как есть (дефолтное поведение BuildRequest).
ForceScheme string
}
// Engine выполняет HTTP-атаку с заданной конкурентностью.
type Engine struct {
client *http.Client
cfg Config
}
// New создаёт Engine с настроенным http.Client (таймаут, TLS).
//
// MaxIdleConnsPerHost/MaxIdleConns заданы явно — у голого http.Transport
// дефолт MaxIdleConnsPerHost всего 2 (значение из net/http.DefaultTransport),
// а Intruder-атака почти всегда бьёт в один и тот же хост множеством
// конкурентных воркеров. С дефолтом это означает, что при Concurrency
// больше двух подавляющее большинство запросов не переиспользует
// соединение из пула — TCP- (а для HTTPS ещё и TLS-) хендшейк на каждый
// запрос вместо одного на воркер. Ставим пул размером с Concurrency —
// ровно столько одновременных соединений к одному хосту нам и нужно.
func New(cfg Config) *Engine {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify},
MaxIdleConnsPerHost: cfg.Concurrency,
MaxIdleConns: cfg.Concurrency * 2, // с запасом на случай редиректов на другой хост
IdleConnTimeout: 90 * time.Second,
}
return &Engine{
client: &http.Client{Timeout: cfg.Timeout, Transport: transport},
cfg: cfg,
}
}
// EstimateTotal вычисляет ожидаемое общее число запросов атаки заранее,
// без её запуска — используется в TUI для индикации прогресса (N/Total).
// Опирается только на payload.Generator.Len() каждого генератора, не
// потребляя их состояние (Len() не двигает Next()/Reset()), поэтому
// безопасно вызывать перед реальным Run с теми же генераторами.
func EstimateTotal(mode AttackMode, points []InsertionPoint, gens []payload.Generator) int {
switch mode {
case ModeSniper:
if len(gens) == 0 {
return 0
}
// RunSniper проходит генератор заново (Reset) на каждую точку —
// то есть суммарно len(points) полных проходов одного генератора.
return len(points) * gens[0].Len()
case ModeBattering:
if len(gens) == 0 {
return 0
}
return gens[0].Len()
case ModePitchfork:
if len(gens) == 0 {
return 0
}
min := -1
for _, g := range gens {
l := g.Len()
if min == -1 || l < min {
min = l
}
}
if min < 0 {
return 0
}
return min
case ModeClusterBomb:
if len(gens) == 0 {
return 0
}
total := 1
for _, g := range gens {
total *= g.Len()
}
return total
default:
return 0
}
}
// Run — единая точка входа для запуска атаки в любом из режимов.
// Сигнатура унифицирована под все три режима через gens []payload.Generator:
//
// - ModeSniper: используется только gens[0], остальные позиции на каждом
// запросе берутся из base (если base == nil, они остаются пустыми
// строками). Если позиций несколько, base обязателен и должен быть
// той же длины, что и points.
// - ModeBattering: используется только gens[0], его значение вставляется
// сразу во все точки.
// - ModePitchfork: используются все gens, len(gens) должен равняться
// len(points) — по генератору на каждую точку, но идут они в ногу
// по индексу, не декартовым произведением.
// - ModeClusterBomb: используются все gens, len(gens) должен равняться
// len(points) — по генератору на каждую точку.
//
// Так TUI/CLI могут работать с одной сигнатурой, не зная деталей
// каждого режима.
func (e *Engine) Run(ctx context.Context, mode AttackMode, clean string, points []InsertionPoint, gens []payload.Generator, base []string) (<-chan Result, error) {
if len(gens) == 0 {
return nil, fmt.Errorf("нужен хотя бы один генератор payload'ов")
}
switch mode {
case ModeSniper:
b := base
if b == nil {
b = make([]string, len(points))
}
if len(b) != len(points) {
return nil, fmt.Errorf("base должен быть длины %d (по числу точек вставки), получено %d", len(points), len(b))
}
return e.RunSniper(ctx, clean, points, gens[0], b), nil
case ModeBattering:
return e.RunBattering(ctx, clean, points, gens[0]), nil
case ModePitchfork:
return e.RunPitchfork(ctx, clean, points, gens)
case ModeClusterBomb:
return e.RunClusterBomb(ctx, clean, points, gens)
default:
return nil, fmt.Errorf("неизвестный режим атаки: %v", mode)
}
}
// readBodyCapped дочитывает r до конца (это важно само по себе — см.
// комментарий в send про keep-alive), считая полную длину total, но
// сохраняя в captured не больше cap байт. Ошибку чтения намеренно не
// прокидывает наверх как фатальную для результата атаки: то, что тело
// оборвалось на середине, не должно скрывать уже полученные статус-код
// и частично прочитанные данные.
func readBodyCapped(r io.Reader, cap int) (captured []byte, total int64, err error) {
buf := make([]byte, 32*1024)
var b bytes.Buffer
for {
n, rerr := r.Read(buf)
if n > 0 {
total += int64(n)
if b.Len() < cap {
remaining := cap - b.Len()
if n < remaining {
remaining = n
}
b.Write(buf[:remaining])
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
return b.Bytes(), total, rerr
}
}
return b.Bytes(), total, nil
}
// RunSniper гоняет атаку в режиме Sniper: один генератор, одна точка
// вставки за проход. Результаты идут в возвращаемый канал.
func (e *Engine) RunSniper(ctx context.Context, clean string, points []InsertionPoint, gen payload.Generator, base []string) <-chan Result {
out := make(chan Result)
type job struct {
pointIdx int
value string
}
jobs := make(chan job)
var wg sync.WaitGroup
for i := 0; i < e.cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
payloads := append([]string(nil), base...)
payloads[j.pointIdx] = j.value
e.send(ctx, clean, points, payloads, out)
}
}()
}
go func() {
defer close(jobs)
for pointIdx := range points {
gen.Reset()
for {
v, ok := gen.Next()
if !ok {
break
}
select {
case jobs <- job{pointIdx: pointIdx, value: v}:
case <-ctx.Done():
return
}
}
}
}()
go func() {
wg.Wait()
close(out)
}()
return out
}
// send выполняет один запрос с заданным набором payload'ов и пишет
// результат в out. Обе потенциально блокирующие операции — отправка в out
// и пауза rate limit'а — слушают ctx.Done(), поэтому отмена контекста
// гарантированно освобождает воркер, даже если читатель канала (TUI)
// уже перестал вычитывать результаты этой атаки.
func (e *Engine) send(ctx context.Context, clean string, points []InsertionPoint, payloads []string, out chan<- Result) {
start := time.Now()
req, err := BuildRequest(clean, points, payloads)
if err != nil {
select {
case out <- Result{Payload: payloads, Err: err}:
case <-ctx.Done():
}
return
}
if e.cfg.ForceScheme != "" {
req.URL.Scheme = e.cfg.ForceScheme
}
req = req.WithContext(ctx)
resp, err := e.client.Do(req)
res := Result{Payload: payloads, Duration: time.Since(start), Err: err, URL: req.URL.String(), Method: req.Method}
if err == nil {
res.StatusCode = resp.StatusCode
res.Headers = resp.Header
body, total, _ := readBodyCapped(resp.Body, maxBodyCapture)
resp.Body.Close()
res.Body = string(body)
res.Length = total
}
select {
case out <- res:
case <-ctx.Done():
return
}
if e.cfg.RateLimit > 0 {
select {
case <-time.After(e.cfg.RateLimit):
case <-ctx.Done():
}
}
}
// RunBattering гоняет атаку в режиме Battering Ram: один генератор,
// каждое его значение вставляется одновременно во все точки вставки
// (в отличие от Sniper, где точки перебираются по очереди, здесь на
// каждый запрос приходится ровно одно значение генератора, но во
// все §маркеры§ сразу — удобно, когда один и тот же токен/ID
// повторяется в нескольких местах запроса).
func (e *Engine) RunBattering(ctx context.Context, clean string, points []InsertionPoint, gen payload.Generator) <-chan Result {
out := make(chan Result)
jobs := make(chan string)
var wg sync.WaitGroup
for i := 0; i < e.cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for v := range jobs {
payloads := make([]string, len(points))
for i := range payloads {
payloads[i] = v
}
e.send(ctx, clean, points, payloads, out)
}
}()
}
go func() {
defer close(jobs)
gen.Reset()
for {
v, ok := gen.Next()
if !ok {
return
}
select {
case jobs <- v:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(out)
}()
return out
}
// RunPitchfork гоняет атаку в режиме Pitchfork: у каждой точки вставки
// свой генератор (len(gens) должен равняться len(points)), но в отличие
// от Cluster Bomb они не перемножаются, а идут в ногу — i-й запрос берёт
// i-е значение из каждого генератора одновременно. Останавливается на
// самом коротком генераторе: если один список payload'ов короче
// остальных, лишние значения из более длинных просто не используются —
// то же поведение, что и в Burp Pitchfork.
func (e *Engine) RunPitchfork(ctx context.Context, clean string, points []InsertionPoint, gens []payload.Generator) (<-chan Result, error) {
if len(gens) != len(points) {
return nil, fmt.Errorf("ожидалось %d генераторов (по числу точек вставки), получено %d", len(points), len(gens))
}
values := make([][]string, len(gens))
minLen := -1
for i, g := range gens {
values[i] = drainGenerator(g)
if len(values[i]) == 0 {
return nil, fmt.Errorf("генератор для точки %q не выдал ни одного payload'а", points[i].Name)
}
if minLen == -1 || len(values[i]) < minLen {
minLen = len(values[i])
}
}
out := make(chan Result)
jobs := make(chan int) // общий индекс i в пределах minLen
var wg sync.WaitGroup
for i := 0; i < e.cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range jobs {
payloads := make([]string, len(points))
for pos := range points {
payloads[pos] = values[pos][idx]
}
e.send(ctx, clean, points, payloads, out)
}
}()
}
go func() {
defer close(jobs)
for i := 0; i < minLen; i++ {
select {
case jobs <- i:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(out)
}()
return out, nil
}
// RunClusterBomb гоняет атаку в режиме Cluster Bomb: каждой точке вставки
// сопоставлен свой генератор (len(gens) должен равняться len(points)),
// и перебираются все комбинации значений (декартово произведение).
//
// Значения каждого генератора вычитываются один раз в память
// (drainGenerator), после чего перебор комбинаций идёт по индексам —
// это ограничивает память суммой длин словарей, а не числом комбинаций,
// которое при N позициях и M вариантах растёт как M^N.
func (e *Engine) RunClusterBomb(ctx context.Context, clean string, points []InsertionPoint, gens []payload.Generator) (<-chan Result, error) {
if len(gens) != len(points) {
return nil, fmt.Errorf("ожидалось %d генераторов (по числу точек вставки), получено %d", len(points), len(gens))
}
values := make([][]string, len(gens))
for i, g := range gens {
values[i] = drainGenerator(g)
if len(values[i]) == 0 {
return nil, fmt.Errorf("генератор для точки %q не выдал ни одного payload'а", points[i].Name)
}
}
out := make(chan Result)
jobs := make(chan []int)
var wg sync.WaitGroup
for i := 0; i < e.cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range jobs {
payloads := make([]string, len(idx))
for pos, vi := range idx {
payloads[pos] = values[pos][vi]
}
e.send(ctx, clean, points, payloads, out)
}
}()
}
go func() {
defer close(jobs)
odometer(values, func(idx []int) bool {
cp := append([]int(nil), idx...)
select {
case jobs <- cp:
return true
case <-ctx.Done():
return false
}
})
}()
go func() {
wg.Wait()
close(out)
}()
return out, nil
}
// drainGenerator вычитывает генератор до конца в слайс значений.
func drainGenerator(g payload.Generator) []string {
g.Reset()
var out []string
for {
v, ok := g.Next()
if !ok {
break
}
out = append(out, v)
}
return out
}
// odometer перебирает все комбинации индексов по values (декартово
// произведение), вызывая emit для каждой комбинации. Работает как счётчик
// с разными основаниями по разрядам: последний разряд крутится быстрее
// всего, при переполнении перенос идёт влево — отсюда и название.
// Останавливается досрочно, если emit вернул false.
func odometer(values [][]string, emit func(idx []int) bool) {
n := len(values)
if n == 0 {
return
}
idx := make([]int, n)
for {
if !emit(idx) {
return
}
pos := n - 1
for pos >= 0 {
idx[pos]++
if idx[pos] < len(values[pos]) {
break
}
idx[pos] = 0
pos--
}
if pos < 0 {
return // все разряды переполнились — перебор завершён
}
}
}

157
internal/engine/filter.go Normal file
View file

@ -0,0 +1,157 @@
package engine
import (
"fmt"
"strconv"
"strings"
)
// StatusFilter решает, проходит ли статус-код фильтр.
type StatusFilter func(code int) bool
// ParseStatusFilter разбирает спецификацию фильтра по статус-коду:
// пустая строка — фильтр не задан (nil, всё проходит); список через
// запятую вида "200,301,404"; диапазоны вида "200-299"; можно смешивать
// в одном списке, напр. "200-299,404".
func ParseStatusFilter(spec string) (StatusFilter, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, nil
}
var preds []func(int) bool
for _, tok := range strings.Split(spec, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
if strings.Contains(tok, "-") {
parts := strings.SplitN(tok, "-", 2)
lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil {
return nil, fmt.Errorf("некорректный диапазон статусов %q", tok)
}
preds = append(preds, func(code int) bool { return code >= lo && code <= hi })
continue
}
n, err := strconv.Atoi(tok)
if err != nil {
return nil, fmt.Errorf("некорректный статус %q", tok)
}
preds = append(preds, func(code int) bool { return code == n })
}
if len(preds) == 0 {
return nil, nil
}
return func(code int) bool {
for _, p := range preds {
if p(code) {
return true
}
}
return false
}, nil
}
// LengthFilter решает, проходит ли длина тела ответа фильтр.
type LengthFilter func(length int64) bool
// ParseLengthFilter разбирает спецификацию фильтра по длине тела:
// пустая строка — фильтр не задан; ">1000", "<500", ">=1000", "<=500" —
// операторы сравнения; "100-500" — диапазон; просто число — точное
// совпадение.
func ParseLengthFilter(spec string) (LengthFilter, error) {
return parseIntRangeFilter(spec)
}
// WordFilter решает, проходит ли число слов в теле ответа фильтр —
// аналог -mw/-fw у ffuf. "Слово" здесь — то же, что возвращает
// strings.Fields (последовательность без пробельных символов).
type WordFilter func(count int64) bool
// ParseWordFilter — тот же синтаксис, что и у ParseLengthFilter, просто
// сравнивается число слов, а не байт.
func ParseWordFilter(spec string) (WordFilter, error) {
return parseIntRangeFilter(spec)
}
// LineFilter решает, проходит ли число строк в теле ответа фильтр —
// аналог -ml/-fl у ffuf.
type LineFilter func(count int64) bool
// ParseLineFilter — тот же синтаксис, что и у остальных числовых фильтров.
func ParseLineFilter(spec string) (LineFilter, error) {
return parseIntRangeFilter(spec)
}
// parseIntRangeFilter — общая операторная грамматика, на которой держатся
// ParseLengthFilter/ParseWordFilter/ParseLineFilter: пустая строка —
// фильтр не задан; ">N", "<N", ">=N", "<=N" — операторы сравнения;
// "N-M" — диапазон; просто число — точное совпадение. Разница между
// тремя фильтрами только в том, что именно считается на стороне
// вызывающего кода (байты/слова/строки) — сам синтаксис один и тот же,
// поэтому парсер один, а не три копии одной и той же логики.
func parseIntRangeFilter(spec string) (func(int64) bool, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, nil
}
parseNum := func(s string) (int64, error) {
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0, fmt.Errorf("некорректное число в %q", spec)
}
return n, nil
}
switch {
case strings.HasPrefix(spec, ">="):
n, err := parseNum(spec[2:])
if err != nil {
return nil, err
}
return func(l int64) bool { return l >= n }, nil
case strings.HasPrefix(spec, "<="):
n, err := parseNum(spec[2:])
if err != nil {
return nil, err
}
return func(l int64) bool { return l <= n }, nil
case strings.HasPrefix(spec, ">"):
n, err := parseNum(spec[1:])
if err != nil {
return nil, err
}
return func(l int64) bool { return l > n }, nil
case strings.HasPrefix(spec, "<"):
n, err := parseNum(spec[1:])
if err != nil {
return nil, err
}
return func(l int64) bool { return l < n }, nil
case strings.Contains(spec, "-"):
parts := strings.SplitN(spec, "-", 2)
lo, err1 := parseNum(parts[0])
hi, err2 := parseNum(parts[1])
if err1 != nil || err2 != nil {
return nil, fmt.Errorf("некорректный диапазон %q", spec)
}
return func(l int64) bool { return l >= lo && l <= hi }, nil
default:
n, err := parseNum(spec)
if err != nil {
return nil, err
}
return func(l int64) bool { return l == n }, nil
}
}

155
internal/engine/request.go Normal file
View file

@ -0,0 +1,155 @@
package engine
import (
"bufio"
"fmt"
"net/http"
"regexp"
"strings"
)
// InsertionPoint описывает одну точку вставки payload'а,
// найденную в шаблоне запроса между маркерами §...§.
type InsertionPoint struct {
Start int // индекс начала в исходной строке (после удаления маркеров)
End int
Name string // человекочитаемое имя для UI, напр. "param:id"
}
// RequestTemplate — сырой HTTP-запрос с маркерами позиций вставки.
type RequestTemplate struct {
Raw string // текст запроса с §маркерами§
Method string
URL string
}
// ParseMarkers вычленяет §...§ из шаблона и возвращает
// чистую строку + список точек вставки. Start/End в InsertionPoint —
// это байтовые смещения в очищенной строке (то, чего ожидает срезовая
// арифметика в BuildRequest), но сканирование ведётся по рунам через
// range — иначе многобайтовый § (U+00A7 кодируется в UTF-8 двумя байтами,
// 0xC2 0xA7) детектится только по второму байту, а первый молча утекает
// в вывод как мусор. По той же причине пишем через b.WriteRune, а не
// b.WriteByte — это же касается и любого другого не-ASCII текста в
// запросе (например, кириллицы в значениях полей).
func ParseMarkers(raw string) (clean string, points []InsertionPoint, err error) {
var b strings.Builder
inMarker := false
start := -1
for _, r := range raw {
if r == '§' {
if !inMarker {
inMarker = true
start = b.Len()
} else {
inMarker = false
points = append(points, InsertionPoint{
Start: start,
End: b.Len(),
Name: fmt.Sprintf("pos%d", len(points)+1),
})
}
continue
}
b.WriteRune(r)
}
if inMarker {
return "", nil, fmt.Errorf("незакрытый маркер § в позиции %d", start)
}
return b.String(), points, nil
}
// InsertBeforeMarker вставляет insertion непосредственно перед index-м по
// счёту §маркером§ в сыром (ещё не прогнанном через ParseMarkers) тексте
// запроса — используется рекурсией в Intruder: нашли директорию по слову
// из словаря, нужно дофаззить уже внутри неё, то есть перед точкой
// вставки должен появиться "найденное_слово/". Возвращает новый сырой
// текст, всё ещё содержащий §маркеры§ — готовый для повторного
// ParseMarkers+BuildRequest, ровно как исходный. Использует тот же
// принцип счёта по рунам, что и DetectParamName (см. её комментарий
// про многобайтовый § и связанный баг, который был здесь исправлен).
func InsertBeforeMarker(raw string, index int, insertion string) (string, error) {
count := -1
for i, r := range raw {
if r != '§' {
continue
}
count++
if count != index*2 {
continue
}
return raw[:i] + insertion + raw[i:], nil
}
return "", fmt.Errorf("маркер с индексом %d не найден", index)
}
// DetectParamName — эвристика для "умной" генерации payload'ов: пытается
// определить имя параметра, стоящего непосредственно перед index-м по
// счёту §маркером§ в сыром (ещё не прогнанном через ParseMarkers) тексте
// запроса. index отсчитывается от 0 в порядке появления маркеров.
// Понимает и query/form-параметры (name=§...§), и JSON-поля
// ("name": "§...§" или "name": §...§). Возвращает пустую строку, если
// определить не удалось — это подсказка, а не гарантия.
func DetectParamName(raw string, index int) string {
count := -1
for i, r := range raw {
if r != '§' {
continue
}
count++
if count != index*2 {
continue
}
if m := paramNamePattern.FindStringSubmatch(raw[:i]); m != nil {
return m[1]
}
return ""
}
return ""
}
// paramNamePattern ищет идентификатор, за которым сразу следует "=" или
// ":" (с возможным пробелом/открывающей кавычкой) непосредственно перед
// концом проверяемого префикса — то есть прямо перед местом, где стоял §.
var paramNamePattern = regexp.MustCompile(`([A-Za-z0-9_\-\.]+)\s*[:=]\s*"?$`)
// BuildRequest подставляет payloads в точки вставки (в обратном порядке,
// чтобы не сбивать индексы) и парсит результат в *http.Request.
func BuildRequest(clean string, points []InsertionPoint, payloads []string) (*http.Request, error) {
if len(payloads) != len(points) {
return nil, fmt.Errorf("ожидалось %d payload'ов, получено %d", len(points), len(payloads))
}
result := clean
for i := len(points) - 1; i >= 0; i-- {
p := points[i]
result = result[:p.Start] + payloads[i] + result[p.End:]
}
return parseRawHTTP(result)
}
// parseRawHTTP разбирает сырой HTTP-запрос (request line + headers + body)
// через стандартный http.ReadRequest. Тело запроса становится ReadCloser,
// пригодным для http.Client.Do.
func parseRawHTTP(raw string) (*http.Request, error) {
reader := bufio.NewReader(strings.NewReader(raw))
req, err := http.ReadRequest(reader)
if err != nil {
return nil, fmt.Errorf("не удалось распарсить сырой HTTP-запрос: %w", err)
}
// http.ReadRequest возвращает запрос с относительным URL и без схемы/хоста —
// достраиваем абсолютный URL из заголовка Host, чтобы http.Client мог
// выполнить запрос напрямую.
if req.URL.Scheme == "" {
req.URL.Scheme = "https"
}
if req.URL.Host == "" {
req.URL.Host = req.Host
}
req.RequestURI = "" // обязательное требование http.Client.Do
return req, nil
}

294
internal/fuzzer/fuzzer.go Normal file
View file

@ -0,0 +1,294 @@
// Package fuzzer реализует blackbox-мутационный фаззер произвольного
// исполняемого файла — аналог AFL без инструментации (никакого coverage
// feedback: цель не пересобирается с санитайзерами/инструментацией, мы
// просто мутируем seed-байты и смотрим, не упала ли программа). Для CTF
// pwn-задач этого обычно достаточно: цель — найти любой крашащий ввод
// на заведомо уязвимом бинарнике, а не покрыть весь граф путей.
//
// Детект краша — по сигналу, которым завершился процесс (SIGSEGV,
// SIGABRT и т.п.), поэтому пакет завязан на syscall.WaitStatus и реально
// работает только на Unix-подобных системах — под Windows exec.ExitError.Sys()
// возвращает другой тип, и isCrashSignal никогда не сработает. Это
// осознанное ограничение: цель — ALT Linux, кросс-платформенность здесь
// не задача.
package fuzzer
import (
"bytes"
"context"
"errors"
"fmt"
"hash/fnv"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
// Config — параметры одного прогона фаззера.
type Config struct {
Target string
// Args — аргументы цели. Один из них — "@@" — заменяется на путь
// к временному файлу с мутированным вводом. Игнорируется, если
// UseStdin true.
Args []string
UseStdin bool // подавать ввод через stdin вместо файла
Timeout time.Duration
Workers int
CrashDir string // куда сохранять крашащие вводы; пусто — не сохранять на диск
}
// EventKind различает тик прогресса и находку — тот же принцип, что
// и scanner.Event: один канал, а не два, чтобы не гонять отдельную
// синхронизацию между потоками прогресса и находок.
type EventKind int
const (
EventProgress EventKind = iota
EventCrash
)
// Crash — один крашащий ввод.
type Crash struct {
Input []byte
Signal string // "SIGSEGV" и т.п., либо "timeout"
Stderr string // обрезанный stderr цели — часто содержит зацепку (напр. "stack smashing detected")
Path string // куда сохранён ввод на диске, пусто — если CrashDir не задан
}
// Event — одно сообщение из канала Fuzzer.Run.
type Event struct {
Kind EventKind
Execs int64 // валидно при EventProgress — суммарный счётчик выполнений
Crash Crash // валидно при EventCrash
}
// Fuzzer гоняет мутации seed-корпуса против одной цели.
type Fuzzer struct {
cfg Config
corpus [][]byte
}
// New проверяет конфигурацию и создаёт Fuzzer. Не запускает ничего сам —
// см. Run.
func New(cfg Config, seeds [][]byte) (*Fuzzer, error) {
if len(seeds) == 0 {
return nil, fmt.Errorf("нужен хотя бы один seed-ввод")
}
if cfg.Workers <= 0 {
cfg.Workers = 4
}
if cfg.Timeout <= 0 {
cfg.Timeout = 2 * time.Second
}
if !cfg.UseStdin && !strings.Contains(strings.Join(cfg.Args, " "), "@@") {
return nil, fmt.Errorf("в args нет плейсхолдера @@ (или включи UseStdin, если цель читает ввод из stdin)")
}
if cfg.CrashDir != "" {
if err := os.MkdirAll(cfg.CrashDir, 0o700); err != nil {
return nil, fmt.Errorf("создание директории для крашей: %w", err)
}
}
return &Fuzzer{cfg: cfg, corpus: seeds}, nil
}
// LoadSeeds читает файлы по путям в срез сэмплов для New. Не разворачивает
// директории рекурсивно — сознательно просто, вызывающий код (TUI) сам
// решает, какие файлы передать.
func LoadSeeds(paths []string) ([][]byte, error) {
seeds := make([][]byte, 0, len(paths))
for _, p := range paths {
b, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("чтение seed %s: %w", p, err)
}
seeds = append(seeds, b)
}
return seeds, nil
}
// Run запускает cfg.Workers параллельных воркеров, каждый в своём цикле:
// взял случайный сэмпл из корпуса → havoc-мутация → запуск цели → если
// краш — эмит события. Отдельная горутина шлёт тик прогресса каждые
// 500мс, чтобы UI видел execs/сек даже когда крашей долго нет. Канал
// закрывается, когда все воркеры и тикер прогресса завершатся (по
// отмене ctx) — оба вида горутин отслеживаются одним sync.WaitGroup,
// иначе закрытие канала могло бы обогнать ещё живого отправителя.
func (f *Fuzzer) Run(ctx context.Context) <-chan Event {
out := make(chan Event)
var execs int64
var wg sync.WaitGroup
for w := 0; w < f.cfg.Workers; w++ {
wg.Add(1)
go func(rngSeed int64) {
defer wg.Done()
rng := rand.New(rand.NewSource(rngSeed))
for {
select {
case <-ctx.Done():
return
default:
}
base := f.corpus[rng.Intn(len(f.corpus))]
input := havoc(rng, base, f.corpus)
crash, err := f.execute(ctx, input)
atomic.AddInt64(&execs, 1)
if err != nil {
continue // ошибка запуска (не найден бинарник и т.п.) — не находка, просто пропускаем итерацию
}
if crash == nil {
continue
}
crash.Path = f.saveCrash(input, *crash)
select {
case out <- Event{Kind: EventCrash, Crash: *crash}:
case <-ctx.Done():
return
}
}
}(time.Now().UnixNano() + int64(w))
}
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case out <- Event{Kind: EventProgress, Execs: atomic.LoadInt64(&execs)}:
case <-ctx.Done():
return
}
}
}
}()
go func() {
wg.Wait()
close(out)
}()
return out
}
// execute запускает цель с input один раз. Возвращает (nil, nil), если
// краша не было; (*Crash, nil) — если был; (nil, err) — если сам запуск
// не удался технически (например, бинарник не найден или нет прав на
// выполнение) — это ошибка конфигурации, не находка фаззера.
func (f *Fuzzer) execute(ctx context.Context, input []byte) (*Crash, error) {
execCtx, cancel := context.WithTimeout(ctx, f.cfg.Timeout)
defer cancel()
args := append([]string(nil), f.cfg.Args...)
if !f.cfg.UseStdin {
tmp, err := os.CreateTemp("", "burterm-fuzz-*")
if err != nil {
return nil, fmt.Errorf("временный файл: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(input); err != nil {
tmp.Close()
return nil, fmt.Errorf("запись временного файла: %w", err)
}
tmp.Close()
for i, a := range args {
if a == "@@" {
args[i] = tmpPath
}
}
}
cmd := exec.CommandContext(execCtx, f.cfg.Target, args...)
if f.cfg.UseStdin {
cmd.Stdin = bytes.NewReader(input)
}
var stderr limitedBuffer
stderr.max = 4096
cmd.Stderr = &stderr
runErr := cmd.Run()
if execCtx.Err() == context.DeadlineExceeded {
return &Crash{Input: input, Signal: "timeout", Stderr: stderr.buf.String()}, nil
}
if runErr == nil {
return nil, nil
}
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() {
sig := status.Signal()
if isCrashSignal(sig) {
return &Crash{Input: input, Signal: sig.String(), Stderr: stderr.buf.String()}, nil
}
}
// Ненулевой код возврата без крашащего сигнала — почти наверняка
// программа сама распознала невалидный ввод и штатно завершилась
// с ошибкой. Это нормальное поведение большинства CLI-утилит,
// не считаем находкой, иначе UI завалило бы ложными "крашами".
return nil, nil
}
return nil, fmt.Errorf("запуск цели: %w", runErr)
}
func isCrashSignal(sig syscall.Signal) bool {
switch sig {
case syscall.SIGSEGV, syscall.SIGABRT, syscall.SIGFPE, syscall.SIGILL, syscall.SIGBUS:
return true
default:
return false
}
}
// saveCrash пишет крашащий ввод на диск с именем на основе сигнала и
// короткого хэша содержимого (fnv — не крипто-хэш, здесь и не нужен,
// просто чтобы разные краши не затирали друг друга одним именем файла).
func (f *Fuzzer) saveCrash(input []byte, crash Crash) string {
if f.cfg.CrashDir == "" {
return ""
}
h := fnv.New32a()
h.Write(input)
name := fmt.Sprintf("crash-%s-%08x", strings.ToLower(strings.TrimPrefix(crash.Signal, "SIG")), h.Sum32())
path := filepath.Join(f.cfg.CrashDir, name)
_ = os.WriteFile(path, input, 0o600)
return path
}
// limitedBuffer — тот же принцип, что и капированные буферы в engine/proxy:
// пишем не больше max байт, но Write всегда сообщает вызывающему "успех"
// (возвращает исходную длину p), чтобы cmd.Run() не считал это ошибкой
// ввода-вывода только из-за того, что нам не нужен весь stderr целиком.
type limitedBuffer struct {
buf bytes.Buffer
max int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
n := len(p)
if remaining := b.max - b.buf.Len(); remaining > 0 {
if len(p) > remaining {
p = p[:remaining]
}
b.buf.Write(p)
}
return n, nil
}

149
internal/fuzzer/mutate.go Normal file
View file

@ -0,0 +1,149 @@
package fuzzer
import (
"encoding/binary"
"math/rand"
)
// mutateFunc — одна стратегия мутации: берёт данные, возвращает новую
// (не модифицирует исходный срез — воркеры работают конкурентно с общим
// корпусом, in-place мутация была бы гонкой данных).
type mutateFunc func(rng *rand.Rand, data []byte) []byte
var mutators = []mutateFunc{
mutateBitFlip,
mutateByteFlip,
mutateArith,
mutateInteresting,
mutateBlockDelete,
mutateBlockDuplicate,
}
func mutateBitFlip(rng *rand.Rand, data []byte) []byte {
if len(data) == 0 {
return data
}
out := append([]byte(nil), data...)
idx := rng.Intn(len(out))
bit := rng.Intn(8)
out[idx] ^= 1 << bit
return out
}
func mutateByteFlip(rng *rand.Rand, data []byte) []byte {
if len(data) == 0 {
return data
}
out := append([]byte(nil), data...)
out[rng.Intn(len(out))] = byte(rng.Intn(256))
return out
}
// interestingBytes/16/32 — классический AFL-набор "граничных" значений:
// нули, минус единица, границы знакового/беззнакового диапазона. Такие
// значения статистически чаще ломают проверки длины/индексов, чем
// случайный байт.
var (
interestingBytes = []byte{0x00, 0x01, 0x7f, 0x80, 0xff}
interesting16 = []int16{0, 1, -1, 0x7fff, -0x8000, 0xff, 0x100}
interesting32 = []int32{0, 1, -1, 0x7fffffff, -0x80000000, 0xffff, 0x10000}
)
func mutateInteresting(rng *rand.Rand, data []byte) []byte {
if len(data) == 0 {
return data
}
out := append([]byte(nil), data...)
switch rng.Intn(3) {
case 0:
out[rng.Intn(len(out))] = interestingBytes[rng.Intn(len(interestingBytes))]
case 1:
if len(out) >= 2 {
idx := rng.Intn(len(out) - 1)
v := interesting16[rng.Intn(len(interesting16))]
binary.LittleEndian.PutUint16(out[idx:], uint16(v))
}
default:
if len(out) >= 4 {
idx := rng.Intn(len(out) - 3)
v := interesting32[rng.Intn(len(interesting32))]
binary.LittleEndian.PutUint32(out[idx:], uint32(v))
}
}
return out
}
// mutateArith — прибавляет/отнимает небольшое значение (1..35, тот же
// диапазон, что ARITH_MAX в AFL) от случайного байта. Ловит off-by-one
// и похожие арифметические баги, которые чистый bit-flip обычно не находит.
func mutateArith(rng *rand.Rand, data []byte) []byte {
if len(data) == 0 {
return data
}
out := append([]byte(nil), data...)
idx := rng.Intn(len(out))
delta := byte(rng.Intn(35) + 1)
if rng.Intn(2) == 0 {
out[idx] += delta
} else {
out[idx] -= delta
}
return out
}
func mutateBlockDelete(rng *rand.Rand, data []byte) []byte {
if len(data) < 2 {
return data
}
start := rng.Intn(len(data))
length := 1 + rng.Intn(len(data)-start)
out := append([]byte(nil), data[:start]...)
out = append(out, data[start+length:]...)
return out
}
func mutateBlockDuplicate(rng *rand.Rand, data []byte) []byte {
if len(data) == 0 {
return data
}
start := rng.Intn(len(data))
length := 1 + rng.Intn(len(data)-start)
block := data[start : start+length]
insertAt := rng.Intn(len(data) + 1)
out := append([]byte(nil), data[:insertAt]...)
out = append(out, block...)
out = append(out, data[insertAt:]...)
return out
}
// splice скрещивает два входа в случайной точке — классическая техника
// "склеить два интересных сэмпла и посмотреть, что получится".
func splice(rng *rand.Rand, a, b []byte) []byte {
if len(a) == 0 || len(b) == 0 {
return append([]byte(nil), a...)
}
n := len(a)
if len(b) < n {
n = len(b)
}
cut := rng.Intn(n)
out := append([]byte(nil), a[:cut]...)
out = append(out, b[cut:]...)
return out
}
// havoc — стадия из классического AFL, которая на практике находит
// больше всего крашей: несколько случайных мутаций подряд за один проход,
// иногда вперемешку со splice по другому сэмплу из корпуса.
func havoc(rng *rand.Rand, data []byte, corpus [][]byte) []byte {
out := append([]byte(nil), data...)
rounds := 1 + rng.Intn(8)
for i := 0; i < rounds; i++ {
if len(corpus) > 1 && rng.Intn(4) == 0 {
out = splice(rng, out, corpus[rng.Intn(len(corpus))])
continue
}
out = mutators[rng.Intn(len(mutators))](rng, out)
}
return out
}

View file

@ -0,0 +1,208 @@
package payload
import "strconv"
// Generator — интерфейс источника payload'ов, чтобы легко
// добавлять новые типы (wordlist, range, brute force, mutation).
type Generator interface {
// Next возвращает следующий payload и false, если генератор исчерпан.
Next() (string, bool)
// Reset позволяет переиспользовать генератор (напр. для sniper-режима,
// где один и тот же генератор прогоняется по каждой точке вставки).
Reset()
// Len возвращает общее количество payload'ов, если оно известно заранее.
Len() int
}
// WordlistGenerator читает payload'ы построчно из слайса (загружен из файла).
type WordlistGenerator struct {
words []string
pos int
}
// NewWordlistGenerator создаёт генератор из уже загруженного списка строк.
func NewWordlistGenerator(words []string) *WordlistGenerator {
return &WordlistGenerator{words: words}
}
func (g *WordlistGenerator) Next() (string, bool) {
if g.pos >= len(g.words) {
return "", false
}
w := g.words[g.pos]
g.pos++
return w, true
}
func (g *WordlistGenerator) Reset() { g.pos = 0 }
func (g *WordlistGenerator) Len() int { return len(g.words) }
// NumericRangeGenerator — для перебора числовых ID (IDOR и т.п.).
type NumericRangeGenerator struct {
current, from, to, step int
}
// NewNumericRangeGenerator создаёт генератор чисел от from до to
// включительно с заданным шагом.
func NewNumericRangeGenerator(from, to, step int) *NumericRangeGenerator {
return &NumericRangeGenerator{current: from, from: from, to: to, step: step}
}
func (g *NumericRangeGenerator) Next() (string, bool) {
if g.current > g.to {
return "", false
}
v := g.current
g.current += g.step
return strconv.Itoa(v), true
}
func (g *NumericRangeGenerator) Reset() { g.current = g.from }
func (g *NumericRangeGenerator) Len() int {
if g.step <= 0 {
return 0
}
return (g.to-g.from)/g.step + 1
}
// ExtensionGenerator оборачивает базовый генератор и добавляет к каждому
// его слову варианты с расширениями — аналог -e у ffuf ("-e .php,.html"
// превращает "admin" в "admin", "admin.php", "admin.html"). Голое слово
// без расширения выдаётся первым — тот же порядок, что у ffuf.
type ExtensionGenerator struct {
base Generator
extensions []string
currentWord string
// extIdx == -1 означает "нужно новое базовое слово"; иначе — индекс
// следующего расширения, которое нужно приклеить к currentWord.
extIdx int
exhausted bool
}
// NewExtensionGenerator оборачивает base, добавляя extensions (каждая
// строка — расширение вида ".php"; ведущая точка добавляется автоматически
// вызывающим кодом, сюда передаётся уже готовая строка).
func NewExtensionGenerator(base Generator, extensions []string) *ExtensionGenerator {
g := &ExtensionGenerator{base: base, extensions: extensions}
g.Reset()
return g
}
func (g *ExtensionGenerator) Reset() {
g.base.Reset()
g.extIdx = -1
g.exhausted = false
}
func (g *ExtensionGenerator) Next() (string, bool) {
if g.exhausted {
return "", false
}
if g.extIdx == -1 {
w, ok := g.base.Next()
if !ok {
g.exhausted = true
return "", false
}
g.currentWord = w
g.extIdx = 0
return w, true // сначала голое слово без расширения
}
if g.extIdx < len(g.extensions) {
ext := g.extensions[g.extIdx]
g.extIdx++
return g.currentWord + ext, true
}
// расширения на это слово кончились — переходим к следующему базовому
g.extIdx = -1
return g.Next()
}
func (g *ExtensionGenerator) Len() int {
return g.base.Len() * (len(g.extensions) + 1) // +1 за голое слово без расширения
}
// BruteForceGenerator перебирает все строки над заданным алфавитом
// длиной от minLen до maxLen включительно — аналог "Brute forcer"
// payload-типа в Burp Intruder. Используется как заготовками "буквы
// алфавита"/"цифры и буквы", так и напрямую через spec "brute:<алфавит>:<min>:<max>".
type BruteForceGenerator struct {
charset []rune
minLen int
maxLen int
length int
idx []int // текущая комбинация индексов в charset для текущей длины
done bool
}
// NewBruteForceGenerator создаёт генератор по алфавиту charset для длин
// от minLen до maxLen включительно.
func NewBruteForceGenerator(charset string, minLen, maxLen int) *BruteForceGenerator {
g := &BruteForceGenerator{charset: []rune(charset), minLen: minLen, maxLen: maxLen}
g.Reset()
return g
}
func (g *BruteForceGenerator) Reset() {
g.length = g.minLen
if g.length < 1 {
g.length = 1
}
g.idx = make([]int, g.length)
g.done = g.minLen > g.maxLen || len(g.charset) == 0
}
func (g *BruteForceGenerator) Next() (string, bool) {
if g.done {
return "", false
}
runes := make([]rune, g.length)
for i, ci := range g.idx {
runes[i] = g.charset[ci]
}
result := string(runes)
// Одометр: продвигаем индексы для следующего вызова, перенос вправо
// налево, как в engine.odometer для Cluster Bomb — тот же принцип,
// просто в одном измерении вместо нескольких точек вставки.
pos := g.length - 1
for pos >= 0 {
g.idx[pos]++
if g.idx[pos] < len(g.charset) {
break
}
g.idx[pos] = 0
pos--
}
if pos < 0 {
g.length++
if g.length > g.maxLen {
g.done = true
} else {
g.idx = make([]int, g.length)
}
}
return result, true
}
// Len — суммарное количество комбинаций по всем длинам от minLen до maxLen.
// Растёт экспоненциально с длиной и размером алфавита — для широких
// алфавитов и maxLen больше 3-4 это может быть очень много; вызывающий
// код (например, TUI) должен относиться к этому числу как к
// предупреждению, а не просто к метрике прогресса.
func (g *BruteForceGenerator) Len() int {
n := len(g.charset)
total := 0
for l := g.minLen; l <= g.maxLen; l++ {
p := 1
for i := 0; i < l; i++ {
p *= n
}
total += p
}
return total
}

114
internal/payload/presets.go Normal file
View file

@ -0,0 +1,114 @@
package payload
import (
"fmt"
"strings"
)
// Preset — фабрика встроенного генератора по короткому имени, чтобы
// в CLI/TUI можно было писать "preset:numbers-small" вместо ручного
// wordlist-файла — но не вместо него: preset — это ещё один вариант
// spec наравне с wordlist:/range:, а не замена. Свои словари как
// работали через wordlist:<путь>, так и продолжают работать.
type Preset struct {
Name string
Description string
New func() Generator
}
var presets = []Preset{
{"numbers-small", "Числа 0-100", func() Generator {
return NewNumericRangeGenerator(0, 100, 1)
}},
{"numbers-large", "Числа 0-9999", func() Generator {
return NewNumericRangeGenerator(0, 9999, 1)
}},
{"alpha-lower", "Строчные буквы a-z, длина 1-2", func() Generator {
return NewBruteForceGenerator("abcdefghijklmnopqrstuvwxyz", 1, 2)
}},
{"alpha-upper", "Заглавные буквы A-Z, длина 1-2", func() Generator {
return NewBruteForceGenerator("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 1, 2)
}},
{"alpha-mixed", "Буквы обоих регистров, длина 1-2", func() Generator {
return NewBruteForceGenerator("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 1, 2)
}},
{"alnum", "Буквы + цифры, длина 1-2", func() Generator {
return NewBruteForceGenerator("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 1, 2)
}},
{"common-usernames", "Частые имена пользователей", func() Generator {
return NewWordlistGenerator(commonUsernames)
}},
{"common-passwords", "Частые пароли", func() Generator {
return NewWordlistGenerator(commonPasswords)
}},
{"sqli-probes", "Базовые SQLi-пробники", func() Generator {
return NewWordlistGenerator(sqliProbes)
}},
{"xss-probes", "Базовые XSS-пробники", func() Generator {
return NewWordlistGenerator(xssProbes)
}},
{"path-traversal", "Path traversal пробники", func() Generator {
return NewWordlistGenerator(pathTraversalProbes)
}},
}
// PresetNames возвращает имена всех встроенных заготовок в порядке
// объявления — используется и для валидации, и для циклического обзора
// в TUI (ctrl+l).
func PresetNames() []string {
names := make([]string, len(presets))
for i, p := range presets {
names[i] = p.Name
}
return names
}
// PresetDescription возвращает человекочитаемое описание заготовки,
// пустую строку — если имя неизвестно.
func PresetDescription(name string) string {
for _, p := range presets {
if p.Name == name {
return p.Description
}
}
return ""
}
// NewFromPreset создаёт генератор по имени встроенной заготовки.
func NewFromPreset(name string) (Generator, error) {
for _, p := range presets {
if p.Name == name {
return p.New(), nil
}
}
return nil, fmt.Errorf("неизвестная заготовка %q (доступные: %s)", name, strings.Join(PresetNames(), ", "))
}
// Встроенные списки — короткие, ознакомительные. Для серьёзного перебора
// (rockyou и подобные) по-прежнему используется свой словарь через
// wordlist:<путь> — заготовки не пытаются его заменить.
var commonUsernames = []string{
"admin", "administrator", "root", "user", "test",
"guest", "demo", "support", "info", "webmaster",
}
var commonPasswords = []string{
"password", "123456", "admin", "letmein", "qwerty",
"password1", "12345678", "admin123", "root", "changeme",
}
var sqliProbes = []string{
`'`, `"`, `' OR '1'='1`, `' OR '1'='1' --`, `" OR "1"="1`,
`1' AND '1'='1`, `' UNION SELECT NULL--`, `1 OR 1=1`, `admin'--`,
}
var xssProbes = []string{
`<script>alert(1)</script>`, `"><script>alert(1)</script>`,
`'><img src=x onerror=alert(1)>`, `<img src=x onerror=alert(1)>`,
`javascript:alert(1)`, `"><svg onload=alert(1)>`,
}
var pathTraversalProbes = []string{
`../`, `../../`, `../../../etc/passwd`,
`..\..\..\windows\win.ini`, `%2e%2e%2f`, `....//....//etc/passwd`,
}

65
internal/payload/smart.go Normal file
View file

@ -0,0 +1,65 @@
package payload
import "strings"
// SmartSuggest возвращает имена заготовок (см. Preset), отсортированные
// по релевантности для параметра с именем paramName. Это эвристика по
// подстрокам в имени — тот же принцип, что и "умная" генерация в Burp:
// там это тоже не ИИ, а сопоставление по имени/типу параметра, просто
// более развитое. Если контекст не распознан, возвращает разведочный
// набор общего назначения вместо пустого списка — так вызывающему коду
// не нужно отдельно обрабатывать случай "ничего не найдено".
func SmartSuggest(paramName string) []string {
name := strings.ToLower(paramName)
var suggestions []string
add := func(names ...string) {
suggestions = append(suggestions, names...)
}
if containsAny(name, "id", "num", "count", "page", "offset", "limit") {
add("numbers-small", "numbers-large")
}
if containsAny(name, "user", "login", "account") {
add("common-usernames")
}
if containsAny(name, "pass", "pwd", "secret") {
add("common-passwords")
}
if containsAny(name, "search", "query", "comment", "message", "name", "text") {
add("xss-probes", "sqli-probes")
}
if containsAny(name, "file", "path", "dir", "folder", "template", "include") {
add("path-traversal")
}
if containsAny(name, "token", "session", "key", "code") {
add("alnum")
}
if len(suggestions) == 0 {
add("sqli-probes", "xss-probes", "numbers-small")
}
return dedupe(suggestions)
}
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
func dedupe(in []string) []string {
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}

View file

@ -0,0 +1,59 @@
package project
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// lastPathFile — файл вне самой БД проекта, хранит путь к последнему
// открытому проекту. Не может жить внутри SQLite-файла проекта по
// очевидной причине: нужно знать путь ДО того, как что-либо открыто.
func lastPathFile() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
}
dir := filepath.Join(home, ".burterm")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("не удалось создать %s: %w", dir, err)
}
return filepath.Join(dir, "last-project"), nil
}
// SaveLastPath запоминает path как последний открытый проект — вызывается
// после каждого успешного Open, чтобы при следующем запуске TUI мог его
// подхватить сам, без необходимости вводить путь заново.
func SaveLastPath(path string) error {
f, err := lastPathFile()
if err != nil {
return err
}
if err := os.WriteFile(f, []byte(path), 0o600); err != nil {
return fmt.Errorf("сохранение последнего пути проекта: %w", err)
}
return nil
}
// LoadLastPath возвращает путь последнего открытого проекта и false,
// если такого файла ещё нет (первый запуск, или проект ни разу не
// открывали) — это не ошибка.
func LoadLastPath() (string, bool, error) {
f, err := lastPathFile()
if err != nil {
return "", false, err
}
b, err := os.ReadFile(f)
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("чтение последнего пути проекта: %w", err)
}
path := strings.TrimSpace(string(b))
if path == "" {
return "", false, nil
}
return path, true, nil
}

71
internal/project/query.go Normal file
View file

@ -0,0 +1,71 @@
package project
import "strings"
// ParseQuery разбирает строку поиска в духе GitHub/Gmail-фильтров:
// токены вида "key:value" распознаются как поля Filter (host, method,
// status, color, minlen, maxlen), всё остальное склеивается и идёт
// в полнотекстовый запрос. Так один текстовый инпут в TUI закрывает
// и структурированную фильтрацию, и полнотекстовый поиск, без десятка
// отдельных полей ввода на экране.
//
// Пример: "host:example.com method:POST status:200-299 color:red токен сессии"
// даёт Filter{Host: "example.com", Method: "POST", Status: "200-299",
// ColorLabel: "red", FTSQuery: "токен сессии"}.
//
// Известный нюанс: если пользователь наберёт в свободнотекстовой части
// что-то похожее на "слово:значение" с неизвестным ключом (не из
// распознаваемого набора), этот токен уйдёт в FTSQuery как есть — а
// FTS5 сам поддерживает синтаксис column-filter с двоеточием, так что
// в редких случаях это может интерпретироваться иначе, чем ожидается.
// Осознанный компромисс ради простоты, а не баг, который стоит чинить
// экранированием на каждый чих.
func ParseQuery(q string) Filter {
var f Filter
var ftsWords []string
for _, tok := range strings.Fields(q) {
idx := strings.Index(tok, ":")
if idx <= 0 {
ftsWords = append(ftsWords, tok)
continue
}
key := strings.ToLower(tok[:idx])
val := tok[idx+1:]
switch key {
case "host":
f.Host = val
case "method":
f.Method = val
case "status":
f.Status = val
case "color":
f.ColorLabel = ColorLabel(val)
case "minlen":
f.MinLength = parseInt64(val)
case "maxlen":
f.MaxLength = parseInt64(val)
default:
ftsWords = append(ftsWords, tok)
}
}
f.FTSQuery = strings.Join(ftsWords, " ")
return f
}
// parseInt64 — терпимый разбор числа: некорректный ввод просто даёт 0
// (что для minlen/maxlen эквивалентно "фильтр не задан"), без лишней
// возни с обработкой ошибки в вызывающем коде ради этого частного случая.
func parseInt64(s string) int64 {
var n int64
for _, r := range s {
if r < '0' || r > '9' {
return 0
}
n = n*10 + int64(r-'0')
}
return n
}

341
internal/project/store.go Normal file
View file

@ -0,0 +1,341 @@
// Package project реализует персистентное хранение сессии burterm —
// перехваченные и отправленные запросы с полнотекстовым поиском и
// фильтрацией. Один файл SQLite = один проект, та же модель, что у
// файлов .burp: открыл, поработал, закрыл, файл можно скопировать
// или отправить коллеге.
//
// Первая внешняя зависимость проекта за пределами bubbletea-стека —
// modernc.org/sqlite (чистый Go, без cgo). См. комментарий в go.mod
// про то, что я не мог скачать/скомпилировать её в этом окружении.
package project
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
"github.com/r3g1tpr0cs/burterm/internal/engine"
)
// ColorLabel — пользовательская цветовая метка запроса, независимая от
// автоматической раскраски по HTTP-статусу в TUI (см. internal/tui/style.go).
// Пустая строка — метки нет.
type ColorLabel string
const (
ColorNone ColorLabel = ""
ColorRed ColorLabel = "red"
ColorOrange ColorLabel = "orange"
ColorYellow ColorLabel = "yellow"
ColorGreen ColorLabel = "green"
ColorCyan ColorLabel = "cyan"
ColorBlue ColorLabel = "blue"
ColorPurple ColorLabel = "purple"
)
// AllColorLabels — для циклического обзора в TUI (клавиша "c" на
// выбранной записи), в порядке цикла.
var AllColorLabels = []ColorLabel{
ColorNone, ColorRed, ColorOrange, ColorYellow, ColorGreen, ColorCyan, ColorBlue, ColorPurple,
}
// NextColor возвращает следующую метку по кругу — используется для
// циклического переключения по одной клавише вместо выбора из списка.
func NextColor(c ColorLabel) ColorLabel {
for i, v := range AllColorLabels {
if v == c {
return AllColorLabels[(i+1)%len(AllColorLabels)]
}
}
return AllColorLabels[0]
}
// Record — одна сохранённая транзакция запрос/ответ.
type Record struct {
ID int64
Source string // "proxy" | "repeater" | "manual" — откуда запись попала в проект
Host string
Method string
URL string
Status int
ContentLength int64
ColorLabel ColorLabel
RawRequest string
RawResponse string
CreatedAt time.Time
UpdatedAt time.Time
}
// schema создаёт таблицу запросов, индексы под самые частые фильтры
// (host/method/status/цвет) и FTS5-индекс с триггерами синхронизации.
// "content='requests', content_rowid='id'" делает FTS5-таблицу внешней
// (external content) — сам текст хранится один раз в requests, а FTS5
// держит только инвертированный индекс, что экономит место на больших
// проектах по сравнению с обычной (дублирующей контент) FTS5-таблицей.
const schema = `
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 0,
content_length INTEGER NOT NULL DEFAULT 0,
color_label TEXT NOT NULL DEFAULT '',
raw_request TEXT NOT NULL DEFAULT '',
raw_response TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_requests_host ON requests(host);
CREATE INDEX IF NOT EXISTS idx_requests_method ON requests(method);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_color ON requests(color_label);
CREATE VIRTUAL TABLE IF NOT EXISTS requests_fts USING fts5(
raw_request, raw_response,
content='requests', content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS requests_ai AFTER INSERT ON requests BEGIN
INSERT INTO requests_fts(rowid, raw_request, raw_response)
VALUES (new.id, new.raw_request, new.raw_response);
END;
CREATE TRIGGER IF NOT EXISTS requests_ad AFTER DELETE ON requests BEGIN
INSERT INTO requests_fts(requests_fts, rowid, raw_request, raw_response)
VALUES('delete', old.id, old.raw_request, old.raw_response);
END;
CREATE TRIGGER IF NOT EXISTS requests_au AFTER UPDATE ON requests BEGIN
INSERT INTO requests_fts(requests_fts, rowid, raw_request, raw_response)
VALUES('delete', old.id, old.raw_request, old.raw_response);
INSERT INTO requests_fts(rowid, raw_request, raw_response)
VALUES (new.id, new.raw_request, new.raw_response);
END;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
// Store — открытый файл проекта.
type Store struct {
db *sql.DB
Path string
}
// Open открывает (или создаёт, если не существует) файл проекта и
// применяет схему. MaxOpenConns намеренно зажат до 1: SQLite не любит
// конкурентных писателей (без явного включения WAL-режима это быстро
// кончается ошибками "database is locked"), а для личного инструмента
// с редкими записями последовательный доступ через один коннекшен —
// самый простой способ этого избежать, без необходимости объяснять
// пользователю про журналирование.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("открытие %s: %w", path, err)
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("подключение к %s: %w", path, err)
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("применение схемы: %w", err)
}
return &Store{db: db, Path: path}, nil
}
// Close закрывает файл проекта.
func (s *Store) Close() error {
return s.db.Close()
}
// Insert сохраняет новую запись и возвращает её ID.
func (s *Store) Insert(r Record) (int64, error) {
now := time.Now().Unix()
res, err := s.db.Exec(
`INSERT INTO requests (source, host, method, url, status, content_length, color_label, raw_request, raw_response, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.Source, r.Host, r.Method, r.URL, r.Status, r.ContentLength, string(r.ColorLabel), r.RawRequest, r.RawResponse, now, now,
)
if err != nil {
return 0, fmt.Errorf("вставка записи: %w", err)
}
return res.LastInsertId()
}
// SetColorLabel обновляет только цветовую метку записи.
func (s *Store) SetColorLabel(id int64, color ColorLabel) error {
_, err := s.db.Exec(`UPDATE requests SET color_label = ?, updated_at = ? WHERE id = ?`, string(color), time.Now().Unix(), id)
if err != nil {
return fmt.Errorf("обновление метки: %w", err)
}
return nil
}
// Delete удаляет запись из проекта.
func (s *Store) Delete(id int64) error {
if _, err := s.db.Exec(`DELETE FROM requests WHERE id = ?`, id); err != nil {
return fmt.Errorf("удаление записи: %w", err)
}
return nil
}
// SaveMeta сохраняет произвольную пару ключ/значение в таблицу meta —
// используется, в частности, для scope-правил вкладки Target, чтобы они
// переживали закрытие и повторное открытие проекта. UPSERT через
// ON CONFLICT, а не отдельные INSERT/UPDATE — меньше кода, та же цель.
func (s *Store) SaveMeta(key, value string) error {
_, err := s.db.Exec(
`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value,
)
if err != nil {
return fmt.Errorf("сохранение meta[%s]: %w", key, err)
}
return nil
}
// LoadMeta возвращает значение по ключу и false, если ключа ещё нет —
// это не ошибка (новый проект просто ещё ничего не сохранял), поэтому
// отдельного sql.ErrNoRows наружу не выпускаем.
func (s *Store) LoadMeta(key string) (string, bool, error) {
var v string
err := s.db.QueryRow(`SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("чтение meta[%s]: %w", key, err)
}
return v, true, nil
}
// Count возвращает общее число записей в проекте.
func (s *Store) Count() (int, error) {
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM requests`).Scan(&n); err != nil {
return 0, fmt.Errorf("подсчёт записей: %w", err)
}
return n, nil
}
// Filter описывает критерии поиска. Пустое/нулевое поле — "не
// фильтровать по этому критерию". Status использует тот же синтаксис,
// что и engine.ParseStatusFilter ("200", "200,404", "200-299") — чтобы
// в приложении был один язык фильтров по статусу, а не два разных.
type Filter struct {
Host string // подстрока хоста
Method string // точное совпадение, регистронезависимо
Status string
MinLength int64
MaxLength int64 // 0 — без верхней границы
ColorLabel ColorLabel
FTSQuery string // полнотекстовый запрос по raw_request/raw_response
Limit, Offset int
}
// Search ищет записи по Filter. Host/Method/ColorLabel/длина и FTS-часть
// выполняются в самом SQL (используют индексы и FTS5), а Status —
// постфактум в Go через engine.ParseStatusFilter, чтобы переиспользовать
// уже проверенный парсер диапазонов/списков статусов вместо того, чтобы
// заново транслировать тот же синтаксис в SQL.
func (s *Store) Search(f Filter) ([]Record, error) {
cols := `r.id, r.source, r.host, r.method, r.url, r.status, r.content_length, r.color_label, r.raw_request, r.raw_response, r.created_at, r.updated_at`
var query string
var args []any
var where []string
if f.FTSQuery != "" {
query = `SELECT ` + cols + ` FROM requests_fts f JOIN requests r ON r.id = f.rowid`
where = append(where, `requests_fts MATCH ?`)
args = append(args, f.FTSQuery)
} else {
query = `SELECT ` + cols + ` FROM requests r`
}
if f.Host != "" {
where = append(where, `r.host LIKE ?`)
args = append(args, "%"+f.Host+"%")
}
if f.Method != "" {
where = append(where, `r.method = ? COLLATE NOCASE`)
args = append(args, f.Method)
}
if f.ColorLabel != "" {
where = append(where, `r.color_label = ?`)
args = append(args, string(f.ColorLabel))
}
if f.MinLength > 0 {
where = append(where, `r.content_length >= ?`)
args = append(args, f.MinLength)
}
if f.MaxLength > 0 {
where = append(where, `r.content_length <= ?`)
args = append(args, f.MaxLength)
}
if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ")
}
query += " ORDER BY r.id DESC"
limit := f.Limit
if limit <= 0 {
limit = 200
}
query += fmt.Sprintf(" LIMIT %d OFFSET %d", limit, f.Offset)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("поиск: %w", err)
}
defer rows.Close()
var out []Record
for rows.Next() {
var r Record
var color string
var createdAt, updatedAt int64
if err := rows.Scan(&r.ID, &r.Source, &r.Host, &r.Method, &r.URL, &r.Status, &r.ContentLength, &color, &r.RawRequest, &r.RawResponse, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("чтение строки: %w", err)
}
r.ColorLabel = ColorLabel(color)
r.CreatedAt = time.Unix(createdAt, 0)
r.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("итерация результатов: %w", err)
}
if f.Status != "" {
pred, err := engine.ParseStatusFilter(f.Status)
if err != nil {
return nil, fmt.Errorf("фильтр по статусу: %w", err)
}
if pred != nil {
filtered := out[:0]
for _, r := range out {
if pred(r.Status) {
filtered = append(filtered, r)
}
}
out = filtered
}
}
return out, nil
}

178
internal/proxy/ca.go Normal file
View file

@ -0,0 +1,178 @@
package proxy
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"sync"
"time"
)
// CA — локальный корневой сертификат для перехвата HTTPS-трафика (MITM),
// по аналогии с Burp CA / mitmproxy CA. Генерируется один раз и
// сохраняется на диск; чтобы браузер перестал ругаться на HTTPS-сайты,
// ca.pem нужно один раз импортировать в доверенные корневые сертификаты
// браузера/ОС.
type CA struct {
cert *x509.Certificate
key *rsa.PrivateKey
leafCache sync.Map // host string -> *tls.Certificate
}
// CADir возвращает директорию для хранения CA (~/.burterm).
func CADir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
}
return filepath.Join(home, ".burterm"), nil
}
// LoadOrCreateCA загружает CA из dir/ca.pem + dir/ca-key.pem, либо создаёт
// новый и сохраняет туда же, если файлов ещё нет.
func LoadOrCreateCA(dir string) (*CA, error) {
certPath := filepath.Join(dir, "ca.pem")
keyPath := filepath.Join(dir, "ca-key.pem")
certBytes, err := os.ReadFile(certPath)
if err == nil {
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("найден ca.pem, но не найден ca-key.pem рядом: %w", err)
}
return parseCA(certBytes, keyBytes)
}
return createCA(dir, certPath, keyPath)
}
func createCA(dir, certPath, keyPath string) (*CA, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("не удалось создать %s: %w", dir, err)
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("генерация ключа CA: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("генерация серийного номера CA: %w", err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "burterm MITM CA",
Organization: []string{"burterm"},
},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("создание сертификата CA: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return nil, fmt.Errorf("сохранение ca.pem: %w", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return nil, fmt.Errorf("сохранение ca-key.pem: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("разбор свежесозданного сертификата CA: %w", err)
}
return &CA{cert: cert, key: key}, nil
}
func parseCA(certPEMBytes, keyPEMBytes []byte) (*CA, error) {
certBlock, _ := pem.Decode(certPEMBytes)
if certBlock == nil {
return nil, fmt.Errorf("ca.pem повреждён или пуст")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("разбор ca.pem: %w", err)
}
keyBlock, _ := pem.Decode(keyPEMBytes)
if keyBlock == nil {
return nil, fmt.Errorf("ca-key.pem повреждён или пуст")
}
key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("разбор ca-key.pem: %w", err)
}
return &CA{cert: cert, key: key}, nil
}
// LeafFor возвращает (создавая и кэшируя при первом обращении) листовой
// TLS-сертификат для host, подписанный этим CA. Используется в
// tls.Config.GetCertificate при перехвате CONNECT-туннеля — по сертификату
// на каждый домен, с которым говорит браузер через прокси.
func (ca *CA) LeafFor(host string) (*tls.Certificate, error) {
if cached, ok := ca.leafCache.Load(host); ok {
return cached.(*tls.Certificate), nil
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("генерация ключа для %s: %w", host, err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("генерация серийного номера для %s: %w", host, err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
DNSNames: []string{host},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key)
if err != nil {
return nil, fmt.Errorf("подпись сертификата для %s: %w", host, err)
}
leaf := &tls.Certificate{
Certificate: [][]byte{der, ca.cert.Raw},
PrivateKey: key,
}
actual, _ := ca.leafCache.LoadOrStore(host, leaf)
return actual.(*tls.Certificate), nil
}
// CertPath — путь к ca.pem, который нужно один раз импортировать
// в доверенные корневые сертификаты браузера/ОС, иначе HTTPS-сайты
// будут показывать предупреждение о недоверенном сертификате.
func (ca *CA) CertPath(dir string) string {
return filepath.Join(dir, "ca.pem")
}

301
internal/proxy/proxy.go Normal file
View file

@ -0,0 +1,301 @@
// Package proxy реализует форвардный HTTP(S)-прокси с перехватом
// трафика (MITM), аналог Burp Proxy / mitmproxy. Браузер настраивается
// ходить через Server.Addr; для HTTP запросы читаются напрямую, для
// HTTPS используется CONNECT-туннель с подменой TLS-сертификата на
// лету (см. CA.LeafFor) — отсюда необходимость один раз импортировать
// ca.pem в доверенные корни браузера/ОС.
//
// Известные упрощения первой версии (не блокирующие базовый сценарий
// перехвата и правки запроса, но стоящие в очереди на доработку):
// - hop-by-hop заголовки (Connection, Proxy-Connection и т.п.) не
// вырезаются перед проксированием;
// - WebSocket-апгрейды не поддерживаются;
// - HTTP/2 на туннеле не форсируется (сервер объявляет только h1
// в ALPN, так что браузер сам откатится на HTTP/1.1 для
// туннелированных доменов).
package proxy
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"sync"
"time"
"github.com/r3g1tpr0cs/burterm/internal/sitemap"
)
// maxResponseCapture — сколько байт тела ответа сохраняется в
// Entry.ResponseBody для полнотекстового поиска в Project. Больше, чем
// в Intruder (4КБ) — трафик через Proxy обычно не такой массовый, можно
// позволить себе больше на хранение.
const maxResponseCapture = 8 * 1024
// Server — форвардный прокси. Каждый прошедший через него запрос
// попадает в Store, откуда его можно подхватить в Request Builder.
type Server struct {
Addr string
CA *CA
Store *Store
// scope, если задан, ограничивает то, что реально попадает в Store —
// сам прокси всё равно обслуживает ЛЮБОЙ трафик (клиент получает
// ответ независимо от scope), просто то, что вне scope, не засоряет
// историю. nil (значение по умолчанию) — логировать всё, как раньше.
// Под мьютексом: SetScope дёргается из горутины TUI, а capture/
// captureErr читают его из совсем других горутин — по одной на
// соединение.
scopeMu sync.RWMutex
scope *sitemap.Scope
client *http.Client
}
// SetScope задаёт (или снимает, если nil) scope, ограничивающий то, что
// попадает в Store. Потокобезопасно относительно capture/captureErr.
// Передаётся независимая копия сверху (см. TUI-слой) — Server не должен
// делить один и тот же *sitemap.Scope с чем-то ещё мутируемым конкурентно,
// иначе Matches() читал бы срез правил без синхронизации с их же записью.
func (s *Server) SetScope(scope *sitemap.Scope) {
s.scopeMu.Lock()
s.scope = scope
s.scopeMu.Unlock()
}
// inScope — под nil-scope пропускает всё (обратная совместимость с
// поведением до появления этой фичи).
func (s *Server) inScope(url string) bool {
s.scopeMu.RLock()
scope := s.scope
s.scopeMu.RUnlock()
if scope == nil {
return true
}
return scope.Matches(url)
}
// New создаёт Server, готовый к ListenAndServe.
func New(addr string, ca *CA, store *Store) *Server {
return &Server{
Addr: addr,
CA: ca,
Store: store,
client: &http.Client{
// Собственный Transport — чтобы не поймать петлю через
// переменные окружения HTTP_PROXY/HTTPS_PROXY.
Transport: &http.Transport{Proxy: nil},
// Редиректы не разворачиваем автоматически — пользователь
// должен увидеть 30x как отдельную перехваченную транзакцию,
// а не только её конечную точку.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// ListenAndServe запускает прокси и блокируется, пока ctx не отменён
// или не произойдёт фатальная ошибка сервера.
func (s *Server) ListenAndServe(ctx context.Context) error {
srv := &http.Server{
Addr: s.Addr,
Handler: http.HandlerFunc(s.serve),
}
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
case err := <-errCh:
return err
}
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
s.serveConnect(w, r)
return
}
s.servePlain(w, r)
}
// servePlain обрабатывает обычный нешифрованный прокси-запрос — браузер
// шлёт его с абсолютным URI в request line (GET http://host/path HTTP/1.1).
func (s *Server) servePlain(w http.ResponseWriter, r *http.Request) {
// DumpRequest вычитывает r.Body и сам восстанавливает его новым
// io.ReadCloser с теми же байтами — r остаётся пригоден для
// дальнейшей пересылки.
dump, _ := httputil.DumpRequest(r, true)
outURL := *r.URL
if outURL.Scheme == "" {
outURL.Scheme = "http"
}
if outURL.Host == "" {
outURL.Host = r.Host
}
outReq, err := http.NewRequest(r.Method, outURL.String(), r.Body)
if err != nil {
s.captureErr(r, err)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
outReq.Header = r.Header.Clone()
resp, err := s.client.Do(outReq)
if err != nil {
s.captureErr(r, err)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Читаем тело целиком — не обрезаем то, что реально уходит клиенту.
// Ошибку чтения намеренно не считаем фатальной: то, что успели
// прочитать, всё равно форвардим и сохраняем как есть.
fullBody, _ := io.ReadAll(resp.Body)
s.capture(r, dump, resp, fullBody)
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
w.Write(fullBody)
}
// serveConnect обрабатывает CONNECT (HTTPS-туннель): хайджекает
// соединение, отвечает "200 Connection Established", затем сам
// выступает TLS-сервером с сертификатом, подписанным локальным CA
// (GetCertificate генерирует лист по SNI на лету), и внутри
// расшифрованного канала читает и пересылает HTTP-запросы как обычный
// клиент — это и есть MITM-перехват.
func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "хайджек соединения не поддерживается", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
return
}
defer clientConn.Close()
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host // Host уже без порта, если SplitHostPort не смог разобрать
}
if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
return
}
tlsConn := tls.Server(clientConn, &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
name := hello.ServerName
if name == "" {
name = host
}
return s.CA.LeafFor(name)
},
})
defer tlsConn.Close()
if err := tlsConn.Handshake(); err != nil {
// Обычно значит, что браузер ещё не доверяет нашему CA —
// это ожидаемо до первого импорта ca.pem, молча выходим.
return
}
br := bufio.NewReader(tlsConn)
for {
req, err := http.ReadRequest(br)
if err != nil {
return
}
// http.ReadRequest для relative-form request line оставляет
// req.URL.Scheme/Host пустыми — достраиваем их из адреса
// CONNECT-туннеля, иначе пересылать запрос некуда.
req.URL.Scheme = "https"
req.URL.Host = r.Host
req.RequestURI = ""
dump, _ := httputil.DumpRequest(req, true)
resp, err := s.client.Do(req)
if err != nil {
s.captureErr(req, err)
return
}
// resp.Write() ниже сам сериализует тело из resp.Body — а нам
// то же тело нужно ещё и для capture(). Тело можно прочитать
// только один раз, поэтому читаем целиком сами и подменяем
// resp.Body свежим io.NopCloser с теми же байтами, прежде чем
// отдать resp дальше — тот же приём, что httputil.DumpRequest
// делает для тела запроса.
fullBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(fullBody))
s.capture(req, dump, resp, fullBody)
writeErr := resp.Write(tlsConn)
if writeErr != nil {
return
}
}
}
func (s *Server) capture(r *http.Request, raw []byte, resp *http.Response, fullBody []byte) {
url := r.URL.String()
if !s.inScope(url) {
return
}
captured := fullBody
if len(captured) > maxResponseCapture {
captured = captured[:maxResponseCapture]
}
s.Store.Add(Entry{
Timestamp: time.Now(),
Method: r.Method,
URL: url,
Raw: string(raw),
StatusCode: resp.StatusCode,
Length: int64(len(fullBody)), // фактически прочитанные байты, не ненадёжный resp.ContentLength (часто -1 при chunked)
Headers: resp.Header,
ResponseBody: string(captured),
})
}
func (s *Server) captureErr(r *http.Request, err error) {
url := ""
if r.URL != nil {
url = r.URL.String()
}
if url != "" && !s.inScope(url) {
return
}
s.Store.Add(Entry{
Timestamp: time.Now(),
Method: r.Method,
URL: url,
Err: fmt.Errorf("проксирование не удалось: %w", err),
})
}

61
internal/proxy/session.go Normal file
View file

@ -0,0 +1,61 @@
package proxy
import (
"net/http"
"net/url"
"strings"
)
// ExtractSessionHeaders сканирует историю в поисках самой свежей записи
// под заданный host и вытаскивает из неё Cookie/Authorization — типичные
// заголовки сессии. Используется Spider, чтобы обходить авторизованные
// зоны сайта без ручного копирования куки: если ты уже походил по сайту
// руками через браузер с настроенным прокси, краулер сам подхватит ту же
// сессию.
//
// Идёт от конца истории к началу (самые новые записи первыми) — сессия
// могла обновиться (перелогин, ротация токена), и должна использоваться
// самая свежая версия, а не первая попавшаяся.
func ExtractSessionHeaders(entries []Entry, host string) http.Header {
h := http.Header{}
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
u, err := url.Parse(e.URL)
if err != nil || u.Host != host {
continue
}
cookie, auth := parseSessionHeadersFromRaw(e.Raw)
if cookie != "" && h.Get("Cookie") == "" {
h.Set("Cookie", cookie)
}
if auth != "" && h.Get("Authorization") == "" {
h.Set("Authorization", auth)
}
if h.Get("Cookie") != "" && h.Get("Authorization") != "" {
break // нашли оба — дальше по истории идти незачем
}
}
return h
}
// parseSessionHeadersFromRaw вытаскивает значения заголовков Cookie и
// Authorization из сырого текста HTTP-запроса (Entry.Raw). Останавливается
// на первой пустой строке — конец заголовков, начало тела, там эти
// заголовки заведомо не встретятся, а тело может быть сколь угодно большим.
func parseSessionHeadersFromRaw(raw string) (cookie, auth string) {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
break
}
lower := strings.ToLower(line)
switch {
case strings.HasPrefix(lower, "cookie:"):
cookie = strings.TrimSpace(line[len("cookie:"):])
case strings.HasPrefix(lower, "authorization:"):
auth = strings.TrimSpace(line[len("authorization:"):])
}
}
return cookie, auth
}

77
internal/proxy/store.go Normal file
View file

@ -0,0 +1,77 @@
package proxy
import (
"net/http"
"sync"
"time"
)
// Entry — одна перехваченная транзакция запрос/ответ, готовая к показу
// в TUI и (по Raw) к загрузке прямо в Request Builder.
type Entry struct {
ID int
Timestamp time.Time
Method string
URL string
Raw string // сырой HTTP-запрос целиком — то, что вставляется в §маркеры§ форму
StatusCode int
Length int64
Headers http.Header // заголовки ответа — для цветовой маркировки по Content-Type в TUI
ResponseBody string // тело ответа, обрезанное до maxResponseCapture байт — для полнотекстового поиска в Project
Err error // не nil, если запрос не удалось выполнить (таймаут, обрыв соединения и т.п.)
}
// Store хранит историю перехваченных запросов и рассылает новые записи
// подписчикам (обычно единственный подписчик — TUI) через каналы.
type Store struct {
mu sync.Mutex
entries []Entry
nextID int
subs []chan Entry
}
// NewStore создаёт пустое хранилище.
func NewStore() *Store {
return &Store{}
}
// Add сохраняет запись, присваивает ей ID и рассылает подписчикам.
// Рассылка неблокирующая: если подписчик не успевает вычитывать канал,
// запись всё равно остаётся в общей истории (доступна через All),
// прокси-сервер из-за медленного TUI не подвиснет.
func (s *Store) Add(e Entry) Entry {
s.mu.Lock()
s.nextID++
e.ID = s.nextID
s.entries = append(s.entries, e)
subs := append([]chan Entry(nil), s.subs...)
s.mu.Unlock()
for _, ch := range subs {
select {
case ch <- e:
default:
}
}
return e
}
// All возвращает копию текущей истории — используется для первичной
// загрузки TUI при старте, если прокси уже что-то успел перехватить.
func (s *Store) All() []Entry {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Entry, len(s.entries))
copy(out, s.entries)
return out
}
// Subscribe возвращает канал, в который будут падать все новые записи,
// добавленные после момента подписки.
func (s *Store) Subscribe() <-chan Entry {
ch := make(chan Entry, 64)
s.mu.Lock()
s.subs = append(s.subs, ch)
s.mu.Unlock()
return ch
}

View file

@ -0,0 +1,22 @@
package scanner
import (
"crypto/rand"
"encoding/hex"
)
// randToken генерирует короткий случайный hex-токен — используется как
// уникальная "канарейка" в XSS/SSTI-пробниках, чтобы отличить реальное
// отражение payload'а от случайного совпадения с обычным контентом
// страницы (тот же принцип, что и у Burp Collaborator-меток, только
// без сетевого колбэка — здесь достаточно уникальности внутри ответа).
func randToken(nBytes int) string {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
// crypto/rand практически никогда не отказывает на нормальной ОС;
// на случай отказа не блокируем сканирование, просто теряем
// часть уникальности токена.
return "wrfallback"
}
return hex.EncodeToString(b)
}

335
internal/scanner/checks.go Normal file
View file

@ -0,0 +1,335 @@
package scanner
import (
"fmt"
"strings"
"time"
)
// timeBasedThreshold — насколько дольше baseline должен быть ответ,
// чтобы засчитать time-based индикатор (для SQLi sleep/waitfor и для
// RCE-инъекций). Пороговое значение осознанно консервативное: медленная
// сеть/сервер сами по себе могут дать пару секунд разброса, а payload'ы
// ниже спят по 5 секунд — трёхсекундный порог оставляет запас.
const timeBasedThreshold = 3 * time.Second
// ---------------------------------------------------------------------
// SQLi
type sqliCheck struct{}
func newSQLiCheck() *sqliCheck { return &sqliCheck{} }
func (c *sqliCheck) Name() string { return "SQLi" }
func (c *sqliCheck) Probes() []string {
return []string{
`'`,
`''`,
`' OR '1'='1`,
`' OR SLEEP(5)-- -`,
`1 OR SLEEP(5)`,
`'; WAITFOR DELAY '0:0:5'--`,
`' AND 1=CONVERT(int,(SELECT @@version))--`,
}
}
func (c *sqliCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
lower := strings.ToLower(resp.Body)
for _, sig := range sqlErrorSignatures {
if strings.Contains(lower, sig) {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "В теле ответа обнаружена сигнатура SQL-ошибки — вероятна SQL-инъекция",
Payload: probe, Evidence: sig,
}
}
}
probeLower := strings.ToLower(probe)
if (strings.Contains(probeLower, "sleep") || strings.Contains(probeLower, "waitfor")) &&
resp.Duration > baseline.Duration+timeBasedThreshold {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: fmt.Sprintf("Time-based индикатор: ответ на %.1fs дольше baseline (payload содержит sleep/waitfor)",
(resp.Duration - baseline.Duration).Seconds()),
Payload: probe,
}
}
return nil
}
var sqlErrorSignatures = []string{
"sql syntax", "you have an error in your sql syntax", "mysql_fetch",
"unclosed quotation mark", "quoted string not properly terminated",
"pg_query", "postgresql", "sqlite3.operationalerror",
"ora-01756", "ora-00933", "ora-00921",
"microsoft ole db provider for sql server", "sqlstate", "syntax error at or near",
}
// ---------------------------------------------------------------------
// XSS (reflected)
type xssCheck struct{ canary string }
func newXSSCheck() *xssCheck {
return &xssCheck{canary: "wr" + randToken(4)}
}
func (c *xssCheck) Name() string { return "XSS (reflected)" }
func (c *xssCheck) Probes() []string {
tag := c.canary
return []string{
fmt.Sprintf(`<%s>alert(1)</%s>`, tag, tag),
fmt.Sprintf(`"><%s>alert(1)</%s>`, tag, tag),
fmt.Sprintf(`'><img src=x onerror=%s(1)>`, tag),
fmt.Sprintf(`javascript:%s(1)`, tag),
}
}
func (c *xssCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
// Ищем канарейку в СЫРОМ виде (с угловыми скобками/как есть) — если
// сервер её экранировал (&lt;wrXXXX&gt;), это не отражение, а
// корректное поведение, и мы не должны его засчитывать как находку.
if strings.Contains(resp.Body, "<"+c.canary) || strings.Contains(resp.Body, c.canary+"(1)") {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Payload отражён в ответе без HTML-экранирования",
Payload: probe, Evidence: c.canary,
}
}
return nil
}
// ---------------------------------------------------------------------
// SSTI
type sstiCheck struct{}
func newSSTICheck() *sstiCheck { return &sstiCheck{} }
func (c *sstiCheck) Name() string { return "SSTI" }
// Множители подобраны так, чтобы их произведение (59975189) было
// достаточно длинным и не встречалось в обычном ответе случайно —
// в отличие от классического {{7*7}}, где "49" легко спутать с частью
// другого числа на странице.
func (c *sstiCheck) Probes() []string {
return []string{
`{{7349*8161}}`, // Jinja2, Twig, Nunjucks
`${7349*8161}`, // FreeMarker, JSP EL, Thymeleaf
`#{7349*8161}`, // Ruby ERB / некоторые Java EL
`<%= 7349*8161 %>`, // ERB explicit
`${{7349*8161}}`, // встречается как обход некоторых фильтров
}
}
func (c *sstiCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
if strings.Contains(resp.Body, "59975189") {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Выражение вычислено сервером — шаблонизатор исполняет входные данные",
Payload: probe, Evidence: "59975189",
}
}
return nil
}
// ---------------------------------------------------------------------
// LFI
type lfiCheck struct{}
func newLFICheck() *lfiCheck { return &lfiCheck{} }
func (c *lfiCheck) Name() string { return "LFI" }
func (c *lfiCheck) Probes() []string {
return []string{
`../../../../etc/passwd`,
`../../../../../../../../etc/passwd`,
`....//....//....//etc/passwd`,
`..%2f..%2f..%2fetc%2fpasswd`,
`/etc/passwd`,
`..\..\..\windows\win.ini`,
`....\\....\\windows\\win.ini`,
}
}
func (c *lfiCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
switch {
case strings.Contains(resp.Body, "root:x:0:0:") || strings.Contains(resp.Body, "root:*:0:0:"):
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "В ответе обнаружено содержимое /etc/passwd", Payload: probe, Evidence: "root:",
}
case strings.Contains(resp.Body, "[boot loader]") || strings.Contains(strings.ToLower(resp.Body), "for 16-bit app support"):
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "В ответе обнаружено содержимое win.ini", Payload: probe, Evidence: "[boot loader]",
}
}
return nil
}
// ---------------------------------------------------------------------
// RCE (командная инъекция, time-based)
type rceCheck struct{}
func newRCECheck() *rceCheck { return &rceCheck{} }
func (c *rceCheck) Name() string { return "RCE (command injection)" }
func (c *rceCheck) Probes() []string {
return []string{
`; sleep 5`,
`| sleep 5`,
"`sleep 5`",
`$(sleep 5)`,
`|| sleep 5`,
`& ping -n 6 127.0.0.1 &`,
}
}
func (c *rceCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
if resp.Duration > baseline.Duration+timeBasedThreshold {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: fmt.Sprintf("Time-based индикатор: ответ на %.1fs дольше baseline",
(resp.Duration - baseline.Duration).Seconds()),
Payload: probe,
}
}
return nil
}
// ---------------------------------------------------------------------
// SSRF (best-effort — см. оговорку в doc пакета)
type ssrfCheck struct{}
func newSSRFCheck() *ssrfCheck { return &ssrfCheck{} }
func (c *ssrfCheck) Name() string { return "SSRF" }
func (c *ssrfCheck) Probes() []string {
return []string{
`http://169.254.169.254/latest/meta-data/`,
`http://localhost/`,
`http://127.0.0.1/`,
`http://[::1]/`,
`file:///etc/passwd`,
}
}
func (c *ssrfCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
lower := strings.ToLower(resp.Body)
if strings.Contains(probe, "169.254.169.254") &&
(strings.Contains(lower, "ami-id") || strings.Contains(lower, "instance-id") || strings.Contains(lower, "iam/security-credentials")) {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "В ответе признаки метаданных облака (AWS IMDS) — сервер выполнил запрос по нашему адресу",
Payload: probe,
}
}
if strings.Contains(probe, "file://") && strings.Contains(resp.Body, "root:x:0:0:") {
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Схема file:// — сервер прочитал локальный файл вместо обращения по сети",
Payload: probe, Evidence: "root:",
}
}
return nil
}
// ---------------------------------------------------------------------
// XXE
type xxeCheck struct{}
func newXXECheck() *xxeCheck { return &xxeCheck{} }
func (c *xxeCheck) Name() string { return "XXE" }
// Probes подставляют payload как ЦЕЛОЕ тело XML-документа — то есть
// имеет смысл только если §маркером§ размечено всё тело запроса
// целиком, а не отдельное значение внутри уже готового XML. Это
// осознанное ограничение модели с одной точкой вставки: реальный XXE
// почти всегда требует контроля над DOCTYPE в начале документа.
func (c *xxeCheck) Probes() []string {
return []string{
`<?xml version="1.0"?><!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><data>&xxe;</data>`,
`<?xml version="1.0"?><!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///c:/windows/win.ini">]><data>&xxe;</data>`,
}
}
func (c *xxeCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
if resp.Err != nil {
return nil
}
switch {
case strings.Contains(resp.Body, "root:x:0:0:"):
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Внешняя сущность разыменована — в ответе содержимое /etc/passwd",
Payload: probe, Evidence: "root:",
}
case strings.Contains(resp.Body, "[boot loader]"):
return &Finding{
Check: c.Name(), Severity: SeverityHigh,
Detail: "Внешняя сущность разыменована — в ответе содержимое win.ini",
Payload: probe, Evidence: "[boot loader]",
}
}
return nil
}
// activeChecks — реестр всех активных проверок. Каждый вызов создаёт
// новые экземпляры (важно для xssCheck/sstiCheck — у них своя
// канарейка/константы на запуск сканирования, не разделяемая между
// прогонами).
func activeChecks() []Check {
return []Check{
newSQLiCheck(),
newXSSCheck(),
newSSTICheck(),
newLFICheck(),
newRCECheck(),
newSSRFCheck(),
newXXECheck(),
}
}
// AllCheckNames возвращает имена всех доступных активных проверок —
// используется в TUI для построения списка с чекбоксами ("не гонять
// всё подряд, если нужен только XSS").
func AllCheckNames() []string {
checks := activeChecks()
names := make([]string, len(checks))
for i, c := range checks {
names[i] = c.Name()
}
return names
}

54
internal/scanner/cors.go Normal file
View file

@ -0,0 +1,54 @@
package scanner
import (
"net/http"
"strings"
)
// CheckCORS отправляет клон req с произвольным Origin-заголовком
// (уникальным на каждый вызов) и смотрит, отражает ли сервер этот
// Origin в Access-Control-Allow-Origin — особенно опасно в сочетании
// с Access-Control-Allow-Credentials: true, что открывает межсайтовый
// доступ к аутентифицированным данным с любого сайта.
//
// В отличие от Check-проверок, CORS не подставляется в §маркер§ —
// здесь модифицируется заголовок запроса, а не тело/параметр, поэтому
// это отдельная функция, а не реализация интерфейса Check.
func CheckCORS(client *http.Client, req *http.Request) (*Finding, error) {
evilOrigin := "https://" + randToken(6) + ".burterm-cors-check.invalid"
probe := req.Clone(req.Context())
probe.Header.Set("Origin", evilOrigin)
resp, err := client.Do(probe)
if err != nil {
return nil, err
}
defer resp.Body.Close()
acao := resp.Header.Get("Access-Control-Allow-Origin")
acac := resp.Header.Get("Access-Control-Allow-Credentials")
switch {
case acao == evilOrigin:
sev := SeverityMedium
detail := "Сервер отражает произвольный Origin в Access-Control-Allow-Origin"
if strings.EqualFold(acac, "true") {
sev = SeverityHigh
detail += " вместе с Access-Control-Allow-Credentials: true — межсайтовый доступ к аутентифицированным данным с любого сайта"
}
return &Finding{Check: "CORS", Severity: sev, Detail: detail, Evidence: acao}, nil
case acao == "*" && strings.EqualFold(acac, "true"):
// Браузер такую комбинацию по спецификации отклонит, но раз
// сервер её всё же выставляет — это сигнал небрежной настройки,
// стоит перепроверить остальные CORS-заголовки вручную.
return &Finding{
Check: "CORS", Severity: SeverityMedium,
Detail: "Access-Control-Allow-Origin: * вместе с Allow-Credentials: true (браузеры отклонят эту комбинацию, но она сигнализирует о небрежной настройке)",
Evidence: acao,
}, nil
}
return nil, nil
}

111
internal/scanner/finding.go Normal file
View file

@ -0,0 +1,111 @@
// Package scanner реализует активные и пассивные проверки на типовые
// классы уязвимостей (аналог Scanner в Burp): SQLi, XSS, SSTI, LFI,
// RCE (командная инъекция), SSRF, XXE, CORS-мисконфигурация, JWT.
//
// Активные проверки (Check) переиспользуют ту же инфраструктуру
// маркеров §...§ и engine.BuildRequest, что и Intruder — по сути,
// Scanner это Intruder с фиксированным набором payload'ов на класс
// уязвимости и анализом ответа вместо простого прогона.
//
// Честная оговорка про качество детекции, важная для трактовки
// результатов:
// - SQLi/LFI/SSTI/XXE — детект по сигнатурам в теле ответа плюс
// time-based эвристика для слепых случаев. Ложноположительные
// срабатывания возможны, особенно на time-based (просто медленный
// сервер даст такой же эффект) — результаты Scanner это наводки
// для ручной проверки, не готовый вердикт.
// - SSRF — без out-of-band листенера (как Burp Collaborator) реальный
// SSRF в общем случае не подтвердить; здесь только best-effort
// проверка через облачные метадата-эндпоинты и file://, которые
// иногда отражаются прямо в ответе.
// - CORS/JWT — не задействуют Probes()/Analyze(), это отдельные
// одноразовые проверки (CheckCORS, DecodeJWT) с другой формой
// входных данных, поэтому не реализуют интерфейс Check.
package scanner
import (
"net/http"
"time"
)
// Severity — грубая оценка серьёзности находки. Не заменяет CVSS и
// подобное — это просто сортировка "на что смотреть в первую очередь".
type Severity int
const (
SeverityInfo Severity = iota
SeverityLow
SeverityMedium
SeverityHigh
)
func (s Severity) String() string {
switch s {
case SeverityInfo:
return "INFO"
case SeverityLow:
return "LOW"
case SeverityMedium:
return "MEDIUM"
case SeverityHigh:
return "HIGH"
default:
return "?"
}
}
// Finding — одна находка проверки.
type Finding struct {
Check string
Severity Severity
Detail string
Payload string // что было отправлено (пусто для CORS/JWT — там нет единого "payload")
Evidence string // фрагмент ответа/заголовка, подтверждающий находку
}
// ProbeResult — минимальный набор данных об ответе, нужный проверкам.
// Отделён от engine.Result по тому же принципу, что sitemap.Record
// отделён от proxy.Entry — scanner не должен тянуть за собой знание
// о внутреннем устройстве Intruder-движка сверх того, что реально нужно.
type ProbeResult struct {
StatusCode int
Body string
Headers http.Header
Duration time.Duration
Err error
}
// Check — активная проверка одного класса уязвимости через подстановку
// payload'ов в одну точку вставки (маркер).
type Check interface {
// Name — человекочитаемое имя, показывается в TUI и в Finding.Check.
Name() string
// Probes — payload'ы, которые по очереди подставляются в маркер.
Probes() []string
// Analyze решает, есть ли находка, сравнивая ответ на probe с
// baseline (ответ на нейтральное/исходное значение той же точки).
// Возвращает nil, если находки нет.
Analyze(probe string, resp, baseline ProbeResult) *Finding
}
// EventKind различает два вида сообщений в потоке Scanner.Run: тик
// прогресса (ещё один пробник отправлен) и собственно находку.
type EventKind int
const (
EventProgress EventKind = iota
EventFinding
)
// Event — одно сообщение из канала Scanner.Run. Kind говорит, какое из
// полей заполнено: Finding (при EventFinding) или Done/Total (при
// EventProgress). Объединение прогресса и находок в один канал/тип —
// осознанное решение: TUI всё равно должен читать их последовательно
// из одного потока, заводить два отдельных канала ради этого не давало
// бы ничего, кроме сложности синхронизации между ними.
type Event struct {
Kind EventKind
Finding Finding // валидно при Kind == EventFinding
Done int // валидно при Kind == EventProgress — сколько пробников уже отправлено
Total int // общее число пробников в этом прогоне (включая baseline)
}

116
internal/scanner/jwt.go Normal file
View file

@ -0,0 +1,116 @@
package scanner
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// JWTInfo — разобранный JWT: header/payload как обычные map (без
// строгой схемы, потому что claims произвольны), плюс findings от
// пассивного анализа.
type JWTInfo struct {
Header map[string]interface{}
Payload map[string]interface{}
Findings []Finding
}
// DecodeJWT разбирает токен на header/payload (без проверки подписи —
// это как раз то, что должна уметь любая программа, читающая JWT, без
// знания секрета) и прогоняет пассивный анализ через analyzeJWT.
func DecodeJWT(token string) (*JWTInfo, error) {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) < 2 {
return nil, fmt.Errorf("не похоже на JWT: ожидалось минимум 2 сегмента через точку, получено %d", len(parts))
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("header: %w", err)
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("payload: %w", err)
}
info := &JWTInfo{}
if err := json.Unmarshal(headerBytes, &info.Header); err != nil {
return nil, fmt.Errorf("header не валидный JSON: %w", err)
}
if err := json.Unmarshal(payloadBytes, &info.Payload); err != nil {
return nil, fmt.Errorf("payload не валидный JSON: %w", err)
}
info.Findings = analyzeJWT(info)
return info, nil
}
// analyzeJWT — пассивные проверки без единого сетевого запроса: смотрим
// только на то, что уже видно в декодированном токене.
func analyzeJWT(info *JWTInfo) []Finding {
var findings []Finding
alg, hasAlg := info.Header["alg"].(string)
switch {
case !hasAlg:
findings = append(findings, Finding{
Check: "JWT", Severity: SeverityMedium,
Detail: "В заголовке нет поля alg — нестандартный токен, стоит разобраться отдельно",
})
case strings.EqualFold(alg, "none"):
findings = append(findings, Finding{
Check: "JWT", Severity: SeverityHigh,
Detail: "Заголовок уже объявляет alg: none — если сервер это принимает, подпись не нужна вообще",
})
case strings.HasPrefix(strings.ToUpper(alg), "HS"):
findings = append(findings, Finding{
Check: "JWT", Severity: SeverityInfo,
Detail: fmt.Sprintf("Симметричный алгоритм %s — стоит попробовать подобрать секрет по словарю (burterm это не делает автоматически, нужен внешний инструмент вроде hashcat)", alg),
})
}
if _, ok := info.Payload["exp"]; !ok {
findings = append(findings, Finding{
Check: "JWT", Severity: SeverityMedium,
Detail: "В payload нет поля exp — токен не истекает",
})
}
return findings
}
// AlgNoneVariant возвращает вариант токена с тем же header (кроме alg,
// заменённого на "none") и тем же payload, но без подписи вообще —
// классический payload для проверки, не принимает ли сервер токены без
// подписи (CVE-класс "JWT alg:none bypass"). Результат нужно вручную
// вставить в запрос (например, через Repeater) — сама подстановка в
// заголовок Authorization зависит от того, как конкретное приложение
// его использует, и это не автоматизируется универсально.
func AlgNoneVariant(token string) (string, error) {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) < 2 {
return "", fmt.Errorf("не похоже на JWT")
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("header: %w", err)
}
var header map[string]interface{}
if err := json.Unmarshal(headerBytes, &header); err != nil {
return "", fmt.Errorf("header не валидный JSON: %w", err)
}
header["alg"] = "none"
newHeaderBytes, err := json.Marshal(header)
if err != nil {
return "", fmt.Errorf("сериализация header: %w", err)
}
newHeader := base64.RawURLEncoding.EncodeToString(newHeaderBytes)
// Подпись убирается целиком — остаётся завершающая точка без
// третьего сегмента, это и есть канонический вид alg:none токена.
return newHeader + "." + parts[1] + ".", nil
}

186
internal/scanner/scanner.go Normal file
View file

@ -0,0 +1,186 @@
package scanner
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"time"
"github.com/r3g1tpr0cs/burterm/internal/engine"
)
// maxBodyCapture — сколько байт тела ответа читаем для анализа сигнатур.
// Больше, чем в Intruder (4КБ), потому что сигнатуры LFI/XXE/SQLi иногда
// прячутся дальше в длинных страницах с логами/трассировками ошибок.
const maxBodyCapture = 16 * 1024
// Config — параметры Scanner-клиента.
type Config struct {
Timeout time.Duration
InsecureSkipVerify bool
}
// Scanner выполняет активные проверки над одной точкой вставки запроса.
type Scanner struct {
client *http.Client
}
// New создаёт Scanner с настроенным http.Client.
func New(cfg Config) *Scanner {
return &Scanner{
client: &http.Client{
Timeout: cfg.Timeout,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}},
},
}
}
// Run запускает выбранные проверки (по именам из checkNames, должны
// совпадать с AllCheckNames) против одной точки вставки pointIndex.
// base задаёт значения всех точек для baseline-запроса и для точек,
// не участвующих в сканировании (та же идея, что и sniper-режим
// в Intruder). Возвращает канал событий (прогресс + находки),
// закрывающийся по завершении всех проверок или по отмене ctx.
func (s *Scanner) Run(ctx context.Context, clean string, points []engine.InsertionPoint, pointIndex int, base []string, checkNames []string) (<-chan Event, error) {
if pointIndex < 0 || pointIndex >= len(points) {
return nil, fmt.Errorf("некорректный индекс точки вставки: %d (всего точек: %d)", pointIndex, len(points))
}
if len(base) != len(points) {
return nil, fmt.Errorf("base должен быть длины %d, получено %d", len(points), len(base))
}
selected := make([]Check, 0, len(checkNames))
for _, c := range activeChecks() {
if containsName(checkNames, c.Name()) {
selected = append(selected, c)
}
}
if len(selected) == 0 {
return nil, fmt.Errorf("не выбрано ни одной проверки")
}
// +1 за baseline-запрос — он тоже реальный HTTP-запрос и тоже
// должен учитываться в прогрессе, иначе N/Total разъедется на 1.
total := 1
for _, check := range selected {
total += len(check.Probes())
}
out := make(chan Event)
go func() {
defer close(out)
done := 0
emit := func(e Event) bool {
select {
case out <- e:
return true
case <-ctx.Done():
return false
}
}
baseline := s.probe(ctx, clean, points, pointIndex, base, base[pointIndex])
done++
if !emit(Event{Kind: EventProgress, Done: done, Total: total}) {
return
}
for _, check := range selected {
for _, p := range check.Probes() {
select {
case <-ctx.Done():
return
default:
}
resp := s.probe(ctx, clean, points, pointIndex, base, p)
done++
if !emit(Event{Kind: EventProgress, Done: done, Total: total}) {
return
}
if finding := check.Analyze(p, resp, baseline); finding != nil {
if !emit(Event{Kind: EventFinding, Finding: *finding}) {
return
}
}
}
}
}()
return out, nil
}
// probe собирает запрос с value в позиции pointIndex (остальные точки —
// из base), отправляет и возвращает ProbeResult. Ошибки сборки/сети не
// прерывают сканирование целиком — просто эта конкретная проверка не
// сработает на этом payload'е (Err передаётся дальше, каждый Check сам
// решает, игнорировать такой ProbeResult или нет — на практике все
// текущие проверки его игнорируют).
func (s *Scanner) probe(ctx context.Context, clean string, points []engine.InsertionPoint, pointIndex int, base []string, value string) ProbeResult {
payloads := append([]string(nil), base...)
payloads[pointIndex] = value
start := time.Now()
req, err := engine.BuildRequest(clean, points, payloads)
if err != nil {
return ProbeResult{Err: err}
}
req = req.WithContext(ctx)
resp, err := s.client.Do(req)
if err != nil {
return ProbeResult{Err: err, Duration: time.Since(start)}
}
defer resp.Body.Close()
body, _ := readBodyCapped(resp.Body, maxBodyCapture)
return ProbeResult{
StatusCode: resp.StatusCode,
Body: string(body),
Headers: resp.Header,
Duration: time.Since(start),
}
}
// readBodyCapped — тот же принцип, что и одноимённая функция в engine
// (см. её комментарий про keep-alive), продублирован здесь, а не
// экспортирован из engine, чтобы scanner не тянул за собой лишнюю
// связанность с внутренним устройством Intruder-движка ради одной
// небольшой функции.
func readBodyCapped(r io.Reader, cap int) ([]byte, error) {
buf := make([]byte, 32*1024)
var b bytes.Buffer
for {
n, rerr := r.Read(buf)
if n > 0 && b.Len() < cap {
remaining := cap - b.Len()
if n < remaining {
remaining = n
}
b.Write(buf[:remaining])
}
if rerr == io.EOF {
break
}
if rerr != nil {
return b.Bytes(), rerr
}
}
return b.Bytes(), nil
}
func containsName(names []string, name string) bool {
for _, n := range names {
if n == name {
return true
}
}
return false
}

224
internal/sequencer/fips.go Normal file
View file

@ -0,0 +1,224 @@
// Package sequencer анализирует случайность набора токенов (сессионных
// ID, CSRF-токенов и т.п.) — аналог вкладки Sequencer в Burp. Реализует
// классический набор статистических тестов FIPS 140-2 (monobit, poker,
// runs, long run), исторически применявшийся именно для этой задачи.
//
// Тесты определены строго для блоков по 20 000 бит: входные сэмплы
// склеиваются в один битовый поток и режутся на такие блоки, остаток
// короче 20 000 бит отбрасывается. Это значит, что для содержательного
// результата нужно ощутимое количество токенов — AnalyzeConcatenated
// возвращает понятную ошибку, если данных не хватает даже на один блок.
package sequencer
import (
"encoding/hex"
"fmt"
"strings"
)
const fipsBlockBits = 20000
// runInterval — допустимый диапазон количества серий данной длины
// для теста Runs, согласно таблице FIPS 140-2.
type runInterval struct{ min, max int }
// runIntervals: длины серий 1..5 — точное совпадение по таблице,
// 6 используется как корзина "6 и длиннее" (в спецификации у неё тот же
// допустимый интервал, что и у длины 5).
var runIntervals = map[int]runInterval{
1: {2343, 2657},
2: {1135, 1365},
3: {542, 708},
4: {251, 373},
5: {111, 201},
6: {111, 201},
}
// BlockResult — результат всех четырёх тестов на одном 20 000-битном блоке.
type BlockResult struct {
MonobitOnes int
MonobitPass bool
PokerX float64
PokerPass bool
ZeroRuns map[int]int
OneRuns map[int]int
RunsPass bool
LongRunMax int
LongRunPass bool
}
// AllPass — блок считается прошедшим анализ, если все четыре теста
// прошли. Один упавший тест не обязательно означает, что генератор
// токенов плох — но чем больше блоков не проходят, тем тревожнее.
func (r BlockResult) AllPass() bool {
return r.MonobitPass && r.PokerPass && r.RunsPass && r.LongRunPass
}
// ParseSample превращает одну строку ввода в байты для анализа: если
// строка целиком выглядит как чётная hex-строка (частый формат для
// сессионных токенов), она декодируется как hex; иначе берутся сырые
// байты UTF-8 самой строки.
func ParseSample(line string) []byte {
line = strings.TrimSpace(line)
if looksHex(line) {
if b, err := hex.DecodeString(line); err == nil {
return b
}
}
return []byte(line)
}
func looksHex(s string) bool {
if s == "" || len(s)%2 != 0 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
// AnalyzeConcatenated склеивает все сэмплы в один битовый поток, режет
// на блоки по 20 000 бит и прогоняет каждый через все четыре теста.
// bitsAvailable — сколько бит суммарно было в сэмплах (для сообщения
// пользователю, сколько ещё нужно добавить, если блоков получилось 0).
func AnalyzeConcatenated(samples [][]byte) (results []BlockResult, blocksUsed int, bitsAvailable int, err error) {
var all []byte
for _, s := range samples {
all = append(all, s...)
}
bits := bytesToBits(all)
bitsAvailable = len(bits)
blocksUsed = bitsAvailable / fipsBlockBits
if blocksUsed == 0 {
return nil, 0, bitsAvailable, fmt.Errorf(
"нужно минимум %d бит для одного FIPS-блока, есть %d — добавь ещё сэмплов",
fipsBlockBits, bitsAvailable,
)
}
results = make([]BlockResult, 0, blocksUsed)
for b := 0; b < blocksUsed; b++ {
block := bits[b*fipsBlockBits : (b+1)*fipsBlockBits]
ones, monoOK := monobitTest(block)
x, pokerOK := pokerTest(block)
zeroRuns, oneRuns, runsOK := runsTest(block)
maxRun, longOK := longRunTest(block)
results = append(results, BlockResult{
MonobitOnes: ones, MonobitPass: monoOK,
PokerX: x, PokerPass: pokerOK,
ZeroRuns: zeroRuns, OneRuns: oneRuns, RunsPass: runsOK,
LongRunMax: maxRun, LongRunPass: longOK,
})
}
return results, blocksUsed, bitsAvailable, nil
}
// bytesToBits разворачивает байты в отдельные биты, старший бит первым.
func bytesToBits(data []byte) []byte {
bits := make([]byte, len(data)*8)
for i, b := range data {
for j := 0; j < 8; j++ {
bits[i*8+j] = (b >> uint(7-j)) & 1
}
}
return bits
}
// monobitTest — доля единиц в блоке должна быть близка к половине.
// block должен быть длиной ровно fipsBlockBits.
func monobitTest(block []byte) (ones int, pass bool) {
for _, b := range block {
ones += int(b)
}
return ones, ones > 9725 && ones < 10275
}
// pokerTest делит блок на 5000 непересекающихся 4-битных сегментов,
// считает распределение по 16 возможным значениям и проверяет
// статистику X на попадание в допустимый интервал по таблице FIPS 140-2.
func pokerTest(block []byte) (x float64, pass bool) {
var freq [16]int
for i := 0; i < 5000; i++ {
v := 0
for j := 0; j < 4; j++ {
v = (v << 1) | int(block[i*4+j])
}
freq[v]++
}
sumSq := 0.0
for _, f := range freq {
sumSq += float64(f) * float64(f)
}
x = (16.0/5000.0)*sumSq - 5000.0
return x, x > 2.16 && x < 46.17
}
// runsTest считает количество серий (подряд идущих одинаковых бит)
// каждой длины отдельно для серий из нулей и из единиц, и сверяет
// с допустимыми интервалами runIntervals. Длины 6 и больше — общая
// корзина.
func runsTest(block []byte) (zeroRuns, oneRuns map[int]int, pass bool) {
zeroRuns = map[int]int{}
oneRuns = map[int]int{}
i := 0
n := len(block)
for i < n {
j := i
for j < n && block[j] == block[i] {
j++
}
length := j - i
bucket := length
if bucket > 6 {
bucket = 6
}
if block[i] == 0 {
zeroRuns[bucket]++
} else {
oneRuns[bucket]++
}
i = j
}
pass = true
for length := 1; length <= 6; length++ {
interval := runIntervals[length]
if zeroRuns[length] < interval.min || zeroRuns[length] > interval.max {
pass = false
}
if oneRuns[length] < interval.min || oneRuns[length] > interval.max {
pass = false
}
}
return zeroRuns, oneRuns, pass
}
// longRunTest — блок не должен содержать серию длиной 26 бит и более
// (ни из нулей, ни из единиц).
func longRunTest(block []byte) (maxRun int, pass bool) {
i := 0
n := len(block)
for i < n {
j := i
for j < n && block[j] == block[i] {
j++
}
if length := j - i; length > maxRun {
maxRun = length
}
i = j
}
return maxRun, maxRun < 26
}

97
internal/sitemap/scope.go Normal file
View file

@ -0,0 +1,97 @@
// Package sitemap строит иерархическую карту сайта из перехваченного
// трафика (аналог Target / Site Map в Burp) и управляет scope —
// набором regex-правил include/exclude, которыми фильтруется, что
// попадает в карту.
//
// Пакет намеренно не зависит от internal/proxy — на вход Build
// принимает свои Record, а не proxy.Entry, чтобы sitemap оставался
// переиспользуемым независимо от того, откуда взялись перехваченные
// запросы (сейчас это Proxy, но с тем же успехом это мог бы быть,
// например, лог из файла).
package sitemap
import (
"fmt"
"regexp"
)
// Rule — одно правило scope: regex-паттерн и его тип (include/exclude).
type Rule struct {
Pattern string
Exclude bool
}
type compiledRule struct {
Rule
re *regexp.Regexp
}
// Scope — упорядоченный набор regex-правил. Порядок добавления не влияет
// на результат сопоставления (см. Matches), но сохраняется для
// отображения в UI в том виде, в котором пользователь их вводил.
type Scope struct {
rules []compiledRule
}
// NewScope создаёт пустой scope — по умолчанию всё считается в scope,
// пока не добавлено хотя бы одно include-правило.
func NewScope() *Scope {
return &Scope{}
}
// Add компилирует pattern и добавляет как include- или exclude-правило.
func (s *Scope) Add(pattern string, exclude bool) error {
re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Errorf("некорректный regex %q: %w", pattern, err)
}
s.rules = append(s.rules, compiledRule{Rule: Rule{Pattern: pattern, Exclude: exclude}, re: re})
return nil
}
// RemoveAt удаляет правило по индексу (в порядке добавления, том же,
// что возвращает Rules). Индекс вне диапазона игнорируется.
func (s *Scope) RemoveAt(idx int) {
if idx < 0 || idx >= len(s.rules) {
return
}
s.rules = append(s.rules[:idx], s.rules[idx+1:]...)
}
// Rules возвращает текущие правила в порядке добавления — для отображения в UI.
func (s *Scope) Rules() []Rule {
out := make([]Rule, len(s.rules))
for i, r := range s.rules {
out[i] = r.Rule
}
return out
}
// Matches решает, попадает ли url в scope:
// - если url совпал с любым exclude-правилом — не в scope, безоговорочно;
// - иначе, если есть хотя бы одно include-правило — url должен совпасть
// хотя бы с одним из них;
// - если include-правил нет вовсе — по умолчанию всё в scope
// (кроме того, что попало под exclude).
func (s *Scope) Matches(url string) bool {
hasInclude := false
includeMatched := false
for _, r := range s.rules {
if r.Exclude {
if r.re.MatchString(url) {
return false
}
continue
}
hasInclude = true
if r.re.MatchString(url) {
includeMatched = true
}
}
if hasInclude {
return includeMatched
}
return true
}

110
internal/sitemap/tree.go Normal file
View file

@ -0,0 +1,110 @@
package sitemap
import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
)
// Record — минимальное описание одного перехваченного запроса,
// достаточное для построения карты. Отделено от proxy.Entry намеренно
// (см. doc пакета) — конвертация делается на стороне вызывающего кода.
type Record struct {
ID int
Method string
URL string
StatusCode int
Err string // непусто, если запрос завершился с ошибкой, а не HTTP-ответом
}
// Endpoint — один запрос, привязанный к конкретному узлу дерева (то есть
// к конкретному пути). На одном узле может быть несколько Endpoint —
// например, GET и POST на один и тот же путь, или один и тот же запрос,
// перехваченный повторно с другим статусом ответа.
type Endpoint struct {
Method string `json:"method"`
StatusCode int `json:"status_code,omitempty"`
URL string `json:"url"`
EntryID int `json:"entry_id"`
Err string `json:"error,omitempty"`
}
// Node — один сегмент пути в дереве (либо хост на верхнем уровне).
type Node struct {
Name string `json:"name"`
Children map[string]*Node `json:"children,omitempty"`
Endpoints []Endpoint `json:"endpoints,omitempty"`
}
func newNode(name string) *Node {
return &Node{Name: name, Children: map[string]*Node{}}
}
// Tree — карта сайта: набор деревьев путей, по одному на каждый
// перехваченный хост.
type Tree struct {
Hosts map[string]*Node `json:"hosts"`
}
// Build строит дерево из записей, отфильтрованных scope. Записи,
// не проходящие Matches, в дерево не попадают вовсе — это соответствует
// поведению Target scope в Burp: то, что вне scope, не засоряет карту.
func Build(records []Record, scope *Scope) *Tree {
t := &Tree{Hosts: map[string]*Node{}}
for _, r := range records {
if scope != nil && !scope.Matches(r.URL) {
continue
}
u, err := url.Parse(r.URL)
if err != nil || u.Host == "" {
continue
}
host, ok := t.Hosts[u.Host]
if !ok {
host = newNode(u.Host)
t.Hosts[u.Host] = host
}
cur := host
for _, seg := range strings.Split(strings.Trim(u.Path, "/"), "/") {
if seg == "" {
continue
}
child, ok := cur.Children[seg]
if !ok {
child = newNode(seg)
cur.Children[seg] = child
}
cur = child
}
cur.Endpoints = append(cur.Endpoints, Endpoint{
Method: r.Method,
StatusCode: r.StatusCode,
URL: r.URL,
EntryID: r.ID,
Err: r.Err,
})
}
return t
}
// ExportJSON сериализует дерево и пишет его в path. encoding/json сам
// сортирует ключи map[string]*Node при маршалинге, так что вывод
// детерминирован без дополнительной сортировки перед записью.
func (t *Tree) ExportJSON(path string) error {
data, err := json.MarshalIndent(t, "", " ")
if err != nil {
return fmt.Errorf("сериализация карты сайта: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("запись файла %s: %w", path, err)
}
return nil
}

286
internal/spider/spider.go Normal file
View file

@ -0,0 +1,286 @@
package spider
import (
"context"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/net/html"
)
// Page — результат обхода одной страницы.
type Page struct {
URL string
Status int
Links []string
Forms []Form
}
// Form описывает найденную HTML-форму.
type Form struct {
Action string
Method string
Fields []string
}
// Spider выполняет ограниченный по scope и глубине обход сайта.
type Spider struct {
scope *url.URL
maxDepth int
client *http.Client
// headers применяются к каждому запросу обхода — сюда попадают
// сессионные Cookie/Authorization, которые TUI подхватывает из
// истории Proxy (см. proxy.ExtractSessionHeaders), чтобы краулинг
// мог заходить в авторизованные зоны без ручного копирования куки.
headers http.Header
visited sync.Map // url string -> struct{}
results chan Page
inProgress sync.WaitGroup
}
// New создаёт Spider, ограниченный доменом scope и глубиной maxDepth.
//
// Собственный Transport, а не голый http.Client{} (который неявно
// использовал бы общий пакетный http.DefaultTransport, разделяемый ещё
// с чем угодно в процессе, случайно на него опершимся) — с тем же
// тюнингом пула соединений, что и в engine.New: bfs рекурсивно плодит
// горутину на каждую найденную ссылку без явного лимита, так что
// конкурентных запросов к одному хосту может набраться заметно больше
// дефолтных двух идle-соединений.
func New(scope *url.URL, maxDepth int) *Spider {
transport := &http.Transport{
MaxIdleConnsPerHost: 50,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
}
return &Spider{
scope: scope,
maxDepth: maxDepth,
client: &http.Client{Transport: transport},
results: make(chan Page, 64),
}
}
// SetHeaders задаёт заголовки, применяемые к каждому запросу обхода.
func (s *Spider) SetHeaders(h http.Header) {
s.headers = h
}
// Crawl запускает BFS обход, ограниченный scope (домен/поддомены)
// и maxDepth. Возвращает канал с найденными страницами; канал
// закрывается, когда обход полностью завершён или ctx отменён.
func (s *Spider) Crawl(ctx context.Context, start string) <-chan Page {
s.inProgress.Add(1)
go s.bfs(ctx, start, 0)
go func() {
s.inProgress.Wait()
close(s.results)
}()
return s.results
}
func (s *Spider) bfs(ctx context.Context, link string, depth int) {
defer s.inProgress.Done()
select {
case <-ctx.Done():
return
default:
}
if depth > s.maxDepth {
return
}
if _, seen := s.visited.LoadOrStore(link, struct{}{}); seen {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
if err != nil {
return
}
for k, vv := range s.headers {
for _, v := range vv {
req.Header.Add(k, v)
}
}
resp, err := s.client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
links, forms := extractLinksAndForms(resp.Body, link)
inScope := make([]string, 0, len(links))
for _, l := range links {
if s.inScope(l) {
inScope = append(inScope, l)
}
}
// Авто-сабмит GET-форм: поисковые поля и фильтры почти всегда ведут
// на GET, и содержимое за ними иначе никогда не попало бы в обход
// (оно не существует как <a href>, пока форму не отправить). POST-формы
// сознательно пропускаем — GET по HTTP-спецификации должен быть
// безопасным/идемпотентным, POST обычно меняет состояние (логин,
// удаление, отправка данных), и автоматически дёргать их было бы
// небезопасно. Оговорка: некоторые сайты нарушают спецификацию и
// делают side-effect'ы даже через GET (напр. "GET-логаут") — это
// осознанный редкий риск ради автоматизации, а не гарантия.
for _, f := range forms {
if !strings.EqualFold(f.Method, "GET") || f.Action == "" {
continue
}
formURL, err := buildGetFormURL(f)
if err != nil {
continue
}
if s.inScope(formURL) {
inScope = append(inScope, formURL)
}
}
select {
case s.results <- Page{
URL: link,
Status: resp.StatusCode,
Links: inScope,
Forms: forms,
}:
case <-ctx.Done():
return
}
for _, next := range inScope {
s.inProgress.Add(1)
go s.bfs(ctx, next, depth+1)
}
}
// buildGetFormURL конструирует URL для GET-формы: берёт Action (уже
// абсолютный — resolve() применяется при извлечении формы) и добавляет
// в query по одному заполнителю на каждое известное поле формы. "test" —
// нейтральное значение-заглушка, не привязанное к конкретному сайту;
// поля, у которых в самом action уже есть значение (редко, но бывает
// в hidden-полях с предзаполненным query), не перезаписываются.
func buildGetFormURL(f Form) (string, error) {
u, err := url.Parse(f.Action)
if err != nil {
return "", err
}
q := u.Query()
for _, name := range f.Fields {
if name == "" || q.Get(name) != "" {
continue
}
q.Set(name, "test")
}
u.RawQuery = q.Encode()
return u.String(), nil
}
// extractLinksAndForms парсит HTML и вытаскивает абсолютные ссылки
// из <a href>, а также описания форм из <form>/<input>.
func extractLinksAndForms(body io.Reader, base string) ([]string, []Form) {
baseURL, err := url.Parse(base)
if err != nil {
return nil, nil
}
doc, err := html.Parse(body)
if err != nil {
return nil, nil
}
var links []string
var forms []Form
var curForm *Form
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "a":
if href, ok := attr(n, "href"); ok {
if abs := resolve(baseURL, href); abs != "" {
links = append(links, abs)
}
}
case "form":
action, _ := attr(n, "action")
method, ok := attr(n, "method")
if !ok {
method = "GET"
}
f := Form{
Action: resolve(baseURL, action),
Method: strings.ToUpper(method),
}
curForm = &f
case "input", "textarea", "select":
if curForm != nil {
if name, ok := attr(n, "name"); ok {
curForm.Fields = append(curForm.Fields, name)
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
if n.Type == html.ElementNode && n.Data == "form" && curForm != nil {
forms = append(forms, *curForm)
curForm = nil
}
}
walk(doc)
return links, forms
}
// attr возвращает значение атрибута узла по имени.
func attr(n *html.Node, key string) (string, bool) {
for _, a := range n.Attr {
if a.Key == key {
return a.Val, true
}
}
return "", false
}
// resolve превращает относительную ссылку в абсолютную относительно base.
// Игнорирует не-http(s) схемы (mailto:, javascript:, tel: и т.п.).
func resolve(base *url.URL, ref string) string {
if ref == "" {
return ""
}
u, err := url.Parse(ref)
if err != nil {
return ""
}
abs := base.ResolveReference(u)
if abs.Scheme != "http" && abs.Scheme != "https" {
return ""
}
abs.Fragment = ""
return abs.String()
}
func (s *Spider) inScope(link string) bool {
u, err := url.Parse(link)
if err != nil {
return false
}
return u.Host == s.scope.Host || strings.HasSuffix(u.Host, "."+s.scope.Host)
}

View file

@ -0,0 +1,157 @@
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/r3g1tpr0cs/burterm/internal/comparer"
)
// comparerModel — вкладка Comparer: два независимых поля ввода (A и B)
// и вычисляемый по запросу токенный diff между ними. Diff не
// пересчитывается на каждое нажатие клавиши (это было бы дорого и
// мельтешило бы перед глазами при печати) — только по ctrl+d.
type comparerModel struct {
left textarea.Model
right textarea.Model
diff viewport.Model
focus int // 0 — left, 1 — right, 2 — diff (скролл)
}
func newComparerView() comparerModel {
left := textarea.New()
left.Placeholder = "Текст A"
left.Focus()
right := textarea.New()
right.Placeholder = "Текст B"
return comparerModel{
left: left,
right: right,
diff: viewport.New(0, 0),
}
}
func (m *comparerModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
inputHeight := height / 4
halfWidth := width / 2
m.left.SetWidth(halfWidth)
m.left.SetHeight(inputHeight)
m.right.SetWidth(width - halfWidth)
m.right.SetHeight(inputHeight)
m.diff.Width = width
// Второй провал точного построчного подсчёта подряд на этой вкладке
// (сначала -5, потом -6, оба раза всё равно вылезало за экран) —
// значит моё предположение о том, что textarea.View() рендерит ровно
// столько строк, сколько задано через SetHeight, скорее всего неверно
// (возможно, у неё есть свой скрытый оверхед — рамка/строка счётчика
// символов/что-то ещё, чего я не вижу в исходниках bubbles без
// реального терминала под рукой). Вместо третьей попытки угадать
// точное число — солидный запас (-12 вместо расчётных 6), плюс
// inputHeight теперь height/4, а не height/3, чтобы оставить больше
// свободного места на непредвиденный оверхед. Цена: diff-viewport
// будет заметно ниже, чем мог бы быть — гарантированная видимость
// меню важнее.
m.diff.Height = height - inputHeight - 12
if m.diff.Height < 3 {
m.diff.Height = 3
}
}
func (m comparerModel) Update(msg tea.Msg) (comparerModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 3
m.left.Blur()
m.right.Blur()
switch m.focus {
case 0:
m.left.Focus()
case 1:
m.right.Focus()
}
return m, nil
case "ctrl+d":
m.runDiff()
return m, nil
}
}
var cmd tea.Cmd
switch m.focus {
case 0:
m.left, cmd = m.left.Update(msg)
case 1:
m.right, cmd = m.right.Update(msg)
case 2:
m.diff, cmd = m.diff.Update(msg)
}
return m, cmd
}
func (m *comparerModel) runDiff() {
chunks := comparer.Diff(m.left.Value(), m.right.Value())
m.diff.SetContent(renderDiffChunks(chunks))
}
// renderDiffChunks размечает удалённые куски как [-...-], добавленные
// как [+...+], а совпадающие оставляет как есть — инлайновая разметка
// вместо честных двух колонок, потому что ширина терминала на мобильном
// экране редко позволяет разместить полноценный side-by-side diff.
func renderDiffChunks(chunks []comparer.Chunk) string {
var b strings.Builder
for _, c := range chunks {
switch c.Op {
case comparer.OpEqual:
b.WriteString(c.Text)
case comparer.OpDelete:
b.WriteString("[-" + c.Text + "-]")
case comparer.OpInsert:
b.WriteString("[+" + c.Text + "+]")
}
}
return b.String()
}
// View рисует A и B рядом (side by side), а не друг под другом — именно
// так и предполагает SetSize (делит ширину пополам через halfWidth).
// Раньше здесь было "A\n[поле]\n\nB\n[поле]", то есть друг под другом —
// это удваивало реальную высоту относительно того, что SetSize
// закладывал в diff.Height, и Comparer стабильно вылезал за экран
// независимо от общего бюджета высоты, который передаёт model.go.
func (m comparerModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
leftBlock := focusMark(0) + "A\n" + m.left.View()
rightBlock := focusMark(1) + "B\n" + m.right.View()
row := lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, " ", rightBlock)
var b strings.Builder
b.WriteString(row)
b.WriteString("\n\n")
b.WriteString(focusMark(2) + "Diff ([-удалено-] / [+добавлено+])\n")
b.WriteString(m.diff.View())
b.WriteString("\n\ntab — между полями · ctrl+d — сравнить\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}

View file

@ -0,0 +1,55 @@
package tui
import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
// dashboardModel — вкладка Dashboard. Сама по себе не знает ни про
// атаки, ни про прокси, ни про краулинг — это намеренно: сбор метрики
// требует доступа к m.attacks/m.proxyView/m.spiderView корневой Model,
// поэтому рендер содержимого живёт в Model.renderDashboard (model.go),
// а dashboardModel — просто прокручиваемая витрина для готовой строки.
type dashboardModel struct {
viewport viewport.Model
}
func newDashboardView() dashboardModel {
return dashboardModel{viewport: viewport.New(0, 0)}
}
func (m *dashboardModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
m.viewport.Width = width
// 3 строки футера, которые View() добавляет сам: пустая строка +
// две строки подсказки (теперь гарантированно на двух строках, не
// на одной — см. комментарий у View()). Раньше здесь не вычиталось
// вообще ничего (тот же класс бага, что был у Proxy) — "работало"
// только по случайному совпадению с тем, что снаружи model.go
// передавал заведомо урезанную высоту.
vh := height - 3
if vh < 3 {
vh = 3
}
m.viewport.Height = vh
}
// SetContent обновляет отображаемый текст — вызывается из Model по тикеру.
func (m *dashboardModel) SetContent(s string) {
m.viewport.SetContent(s)
}
func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m dashboardModel) View() string {
return m.viewport.View() + "\n\n↑/↓ — прокрутка · обновляется раз в секунду\nf1-f12, pgup — вкладки\n"
}

191
internal/tui/decoderview.go Normal file
View file

@ -0,0 +1,191 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/decoder"
)
// decoderModel — вкладка Decoder: вход + выход, набор кодировок за
// ctrl-хоткеями (работают независимо от того, в каком поле курсор —
// как и остальные ctrl-комбинации в приложении, они не печатаются как
// текст, поэтому их можно ловить прямо поверх textarea.Update) и тумблер
// направления encode/decode.
type decoderModel struct {
input textarea.Model
output viewport.Model
encode bool // true — кодировать, false — декодировать (для MD5 неприменимо)
lastOp string
errMsg string
focus int // 0 — вход, 1 — выход (скролл)
}
func newDecoderView() decoderModel {
ta := textarea.New()
ta.Placeholder = "Вставь текст для кодирования/декодирования"
ta.Focus()
return decoderModel{
input: ta,
output: viewport.New(0, 0),
encode: true,
}
}
func (m *decoderModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
inHeight := height / 2
m.input.SetWidth(width)
m.input.SetHeight(inHeight)
m.output.Width = width
// 8 фиксированных строк вокруг output-viewport (построчный подсчёт по
// View(): "Вход"-метка 1, разделитель 1, строка статуса 1,
// разделитель+"Выход"-метка 2, разделитель+футер(две строки) 3 — итого
// 8). Раньше здесь стояло -4 — не совпадало с тем, что реально рисует
// View(), причём задолго до того, как футер стал двухстрочным: это
// самостоятельная, отдельная от разбиения футера ошибка в подсчёте.
m.output.Height = height - inHeight - 8
}
func (m decoderModel) Update(msg tea.Msg) (decoderModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 2
if m.focus == 0 {
m.input.Focus()
} else {
m.input.Blur()
}
return m, nil
case "ctrl+z":
m.encode = !m.encode
return m, nil
case "ctrl+u":
m.apply("URL", func(s string) (string, error) {
if m.encode {
return decoder.URLEncode(s), nil
}
return decoder.URLDecode(s)
})
return m, nil
case "ctrl+b":
m.apply("Base64", func(s string) (string, error) {
if m.encode {
return decoder.Base64Encode(s), nil
}
return decoder.Base64Decode(s)
})
return m, nil
case "ctrl+e":
m.apply("HTML", func(s string) (string, error) {
if m.encode {
return decoder.HTMLEncode(s), nil
}
return decoder.HTMLDecode(s), nil
})
return m, nil
case "ctrl+x":
m.apply("Hex", func(s string) (string, error) {
if m.encode {
return decoder.HexEncode(s), nil
}
return decoder.HexDecode(s)
})
return m, nil
case "ctrl+k":
m.errMsg = ""
m.lastOp = "MD5"
m.output.SetContent(decoder.MD5Hash(m.input.Value()))
return m, nil
case "ctrl+a":
m.autoDecode()
return m, nil
}
}
var cmd tea.Cmd
if m.focus == 0 {
m.input, cmd = m.input.Update(msg)
} else {
m.output, cmd = m.output.Update(msg)
}
return m, cmd
}
func (m *decoderModel) apply(name string, fn func(string) (string, error)) {
out, err := fn(m.input.Value())
if err != nil {
m.errMsg = fmt.Sprintf("%s: %v", name, err)
return
}
m.errMsg = ""
dir := "encode"
if !m.encode {
dir = "decode"
}
m.lastOp = name + " " + dir
m.output.SetContent(out)
}
func (m *decoderModel) autoDecode() {
steps := decoder.SmartDecode(m.input.Value())
if len(steps) == 0 {
m.errMsg = "не удалось распознать ни одной известной кодировки"
return
}
m.errMsg = ""
var b strings.Builder
for i, s := range steps {
b.WriteString(fmt.Sprintf("[%d] %s:\n%s\n\n", i+1, s.Operation, s.Result))
}
m.lastOp = fmt.Sprintf("авто-цепочка (%d шаг(ов))", len(steps))
m.output.SetContent(b.String())
}
func (m decoderModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
dir := "encode"
if !m.encode {
dir = "decode"
}
var b strings.Builder
b.WriteString(focusMark(0) + "Вход\n")
b.WriteString(m.input.View())
b.WriteString("\n\n")
status := fmt.Sprintf("Направление: %s (ctrl+z)", dir)
if m.lastOp != "" {
status += " · последняя операция: " + m.lastOp
}
b.WriteString(status + "\n")
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n")
}
b.WriteString("\n" + focusMark(1) + "Выход\n")
b.WriteString(m.output.View())
b.WriteString("\n\nctrl+u URL · ctrl+b Base64 · ctrl+e HTML · ctrl+x Hex · ctrl+k MD5 · ctrl+a авто-цепочка\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}

335
internal/tui/fuzzerview.go Normal file
View file

@ -0,0 +1,335 @@
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 "файл (@@)"
}

1413
internal/tui/model.go Normal file

File diff suppressed because it is too large Load diff

285
internal/tui/projectview.go Normal file
View file

@ -0,0 +1,285 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/project"
)
// projectModel — вкладка Project: путь к файлу проекта (SQLite),
// поисковая строка в мини-языке (host:/method:/status:/color:/minlen:/
// maxlen: + свободный текст для FTS), список результатов с цветовыми
// метками. store хранится и здесь, и на корневой Model (m.projectStore)
// — небольшое дублирование указателя ради того, чтобы вкладка сама
// могла строить свои команды (поиск/цвет/удаление), не прокидывая
// store через каждое сообщение.
type projectModel struct {
store *project.Store
path string
pathInput textinput.Model
queryInput textinput.Model
results []project.Record
cursor int
viewport viewport.Model
errMsg string
statusMsg string
focus int // 0 — путь, 1 — поисковый запрос, 2 — список результатов
}
func newProjectView() projectModel {
pathIn := textinput.New()
pathIn.Placeholder = "~/.burterm/projects/target.db"
pathIn.CharLimit = 512
pathIn.Focus()
queryIn := textinput.New()
queryIn.Placeholder = "host:example.com method:POST status:200-299 color:red текст для полнотекстового поиска"
queryIn.CharLimit = 512
return projectModel{
pathInput: pathIn,
queryInput: queryIn,
viewport: viewport.New(0, 0),
}
}
func (m *projectModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
m.viewport.Width = width
// 11 фиксированных строк (построчный подсчёт: метка+поле+разделитель
// на "Файл проекта" (3) и на "Поиск" (3), блок "Найдено: N"+пустая
// (2), разделитель+футер(две строки) (3) — итого 11). Раньше стояло
// -8 — недосчитано с самого начала, безотносительно разбиения
// футера. Оговорка: минимум для состояния без ошибки/statusMsg —
// с ними на 1-2 строки больше.
vh := height - 11
if vh < 3 {
vh = 3
}
m.viewport.Height = vh
}
// refreshList перерисовывает viewport из m.results — вызывается после
// любого изменения списка (поиск, смена метки, удаление).
func (m *projectModel) refreshList() {
if len(m.results) == 0 {
m.viewport.SetContent("Пусто. Проверь запрос или сохрани что-нибудь в проект.")
return
}
var b strings.Builder
for i, r := range m.results {
marker := " "
if m.focus == 2 && i == m.cursor {
marker = "▸ "
}
colorText := colorLabelStyle(r.ColorLabel).Render(fmt.Sprintf("%-6s", colorName(r.ColorLabel)))
b.WriteString(fmt.Sprintf("%s#%-5d %-6s %-4d %s %-6s %s\n", marker, r.ID, r.Method, r.Status, colorText, humanBytes(uint64(max64(r.ContentLength, 0))), r.URL))
}
m.viewport.SetContent(b.String())
}
func colorName(c project.ColorLabel) string {
if c == "" {
return "-"
}
return string(c)
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func (m projectModel) Update(msg tea.Msg) (projectModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 3
m.pathInput.Blur()
m.queryInput.Blur()
switch m.focus {
case 0:
m.pathInput.Focus()
case 1:
m.queryInput.Focus()
}
m.refreshList()
return m, nil
case "ctrl+o":
m.errMsg = ""
m.statusMsg = ""
return m, m.openCmd()
case "enter":
switch m.focus {
case 1:
m.errMsg = ""
return m, m.searchCmd()
case 2:
if m.cursor >= 0 && m.cursor < len(m.results) {
rec := m.results[m.cursor]
return m, func() tea.Msg { return projectLoadMsg{record: rec, target: targetBuilder} }
}
}
return m, nil
case "up":
if m.focus == 2 && m.cursor > 0 {
m.cursor--
m.refreshList()
return m, nil
}
case "down":
if m.focus == 2 && m.cursor < len(m.results)-1 {
m.cursor++
m.refreshList()
return m, nil
}
case "s":
if m.focus == 2 && m.cursor < len(m.results) {
rec := m.results[m.cursor]
return m, func() tea.Msg { return projectLoadMsg{record: rec, target: targetRepeater} }
}
case "c":
if m.focus == 2 {
return m, m.cycleColorCmd()
}
case "d":
if m.focus == 2 {
return m, m.deleteCmd()
}
}
}
var cmd tea.Cmd
switch m.focus {
case 0:
m.pathInput, cmd = m.pathInput.Update(msg)
case 1:
m.queryInput, cmd = m.queryInput.Update(msg)
case 2:
m.viewport, cmd = m.viewport.Update(msg)
}
return m, cmd
}
// openCmd (ctrl+o) открывает или создаёт файл проекта по пути из pathInput.
// Заодно запоминает path как последний открытый проект (project.SaveLastPath)
// — при следующем запуске TUI сам его подхватит. Ошибку сохранения пути
// намеренно не считаем фатальной для самого открытия: то, что не удалось
// запомнить "последний проект" на будущее, не должно мешать работать
// с уже успешно открытым файлом прямо сейчас.
func (m projectModel) openCmd() tea.Cmd {
path := strings.TrimSpace(m.pathInput.Value())
return func() tea.Msg {
if path == "" {
return projectErrMsg{fmt.Errorf("укажи путь к файлу проекта")}
}
store, err := project.Open(path)
if err != nil {
return projectErrMsg{err}
}
_ = project.SaveLastPath(path)
return projectOpenedMsg{store: store, path: path}
}
}
// searchCmd (enter в строке запроса) разбирает мини-язык через
// project.ParseQuery и выполняет поиск в открытом проекте.
func (m projectModel) searchCmd() tea.Cmd {
if m.store == nil {
return func() tea.Msg { return projectErrMsg{fmt.Errorf("сначала открой проект (ctrl+o)")} }
}
q := m.queryInput.Value()
store := m.store
return func() tea.Msg {
filter := project.ParseQuery(q)
results, err := store.Search(filter)
if err != nil {
return projectErrMsg{err}
}
return projectSearchMsg{results: results}
}
}
// cycleColorCmd (клавиша "c" на выбранной записи) переключает цветовую
// метку по кругу и сохраняет изменение в БД.
func (m projectModel) cycleColorCmd() tea.Cmd {
if m.store == nil || m.cursor < 0 || m.cursor >= len(m.results) {
return nil
}
rec := m.results[m.cursor]
store := m.store
next := project.NextColor(rec.ColorLabel)
return func() tea.Msg {
if err := store.SetColorLabel(rec.ID, next); err != nil {
return projectErrMsg{err}
}
return projectColorSetMsg{id: rec.ID, color: next}
}
}
// deleteCmd (клавиша "d" на выбранной записи) удаляет её из проекта.
func (m projectModel) deleteCmd() tea.Cmd {
if m.store == nil || m.cursor < 0 || m.cursor >= len(m.results) {
return nil
}
rec := m.results[m.cursor]
store := m.store
return func() tea.Msg {
if err := store.Delete(rec.ID); err != nil {
return projectErrMsg{err}
}
return projectDeletedMsg{id: rec.ID}
}
}
func (m projectModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
openStatus := "нет открытого проекта"
if m.store != nil {
openStatus = m.path
}
var b strings.Builder
b.WriteString(focusMark(0) + fmt.Sprintf("Файл проекта (ctrl+o — открыть/создать) — сейчас: %s\n", openStatus))
b.WriteString(m.pathInput.View())
b.WriteString("\n\n")
b.WriteString(focusMark(1) + "Поиск (enter — искать)\n")
b.WriteString(m.queryInput.View())
b.WriteString("\n\n")
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n")
}
if m.statusMsg != "" {
b.WriteString(m.statusMsg + "\n")
}
b.WriteString(fmt.Sprintf("Найдено: %d\n\n", len(m.results)))
b.WriteString(focusMark(2) + m.viewport.View())
b.WriteString("\n\ntab — фокус · enter — искать/загрузить · s — в Repeater · c — цвет · d — удалить\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}

121
internal/tui/proxyview.go Normal file
View file

@ -0,0 +1,121 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/proxy"
)
// proxyModel — список перехваченных прокси-транзакций с курсором выбора.
// Enter на выбранной строке отправляет её сырой запрос в Request Builder
// (через loadEntryMsg, обрабатывается в корневой модели).
type proxyModel struct {
entries []proxy.Entry
cursor int
viewport viewport.Model
}
func newProxyView() proxyModel {
return proxyModel{viewport: viewport.New(0, 0)}
}
func (m *proxyModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
m.viewport.Width = width
// 6 строк вокруг viewport, которые View() добавляет сам: "Перехвачено:
// N" + пустая строка сверху, пустая строка + две строки подсказки
// снизу (футер теперь гарантированно на двух строках, не на одной).
vh := height - 6
if vh < 3 {
vh = 3
}
m.viewport.Height = vh
m.refresh()
}
// AddEntry дописывает новую перехваченную транзакцию в список.
func (m *proxyModel) AddEntry(e proxy.Entry) {
m.entries = append(m.entries, e)
m.refresh()
}
func (m *proxyModel) refresh() {
if len(m.entries) == 0 {
m.viewport.SetContent("Пока пусто. Направь браузер на прокси-адрес и походи по нужному сайту.")
return
}
var b strings.Builder
for i, e := range m.entries {
marker := " "
if i == m.cursor {
marker = "▸ "
}
ct := contentTypeLabel(e.Headers)
if ct == "" {
ct = "-"
}
var status string
if e.Err != nil {
status = styleErrStatus.Render(fmt.Sprintf("%-4s", "ERR"))
} else {
status = statusStyle(e.StatusCode).Render(fmt.Sprintf("%-4d", e.StatusCode))
}
b.WriteString(fmt.Sprintf("%s#%-4d %-6s %s %-6s %s\n", marker, e.ID, e.Method, status, ct, e.URL))
}
m.viewport.SetContent(b.String())
}
func (m proxyModel) Update(msg tea.Msg) (proxyModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
m.refresh()
}
return m, nil
case "down", "j":
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refresh()
}
return m, nil
case "enter":
if m.cursor >= 0 && m.cursor < len(m.entries) {
entry := m.entries[m.cursor]
return m, func() tea.Msg { return loadEntryMsg{entry: entry, target: targetBuilder} }
}
return m, nil
case "s":
if m.cursor >= 0 && m.cursor < len(m.entries) {
entry := m.entries[m.cursor]
return m, func() tea.Msg { return loadEntryMsg{entry: entry, target: targetRepeater} }
}
return m, nil
}
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m proxyModel) View() string {
return fmt.Sprintf(
"Перехвачено: %d\n\n%s\n\n↑/↓ — выбор · enter — в Request Builder · s — в Repeater\nf1-f12, pgup — вкладки",
len(m.entries), m.viewport.View(),
)
}

269
internal/tui/repeater.go Normal file
View file

@ -0,0 +1,269 @@
package tui
import (
"crypto/tls"
"fmt"
"net/http"
"net/http/httputil"
neturl "net/url"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/comparer"
"github.com/r3g1tpr0cs/burterm/internal/engine"
"github.com/r3g1tpr0cs/burterm/internal/project"
)
// repeaterModel — аналог Burp Repeater: один редактируемый запрос,
// который можно поправить и отправить сколько угодно раз подряд,
// разглядывая сырой ответ. В отличие от Request Builder, здесь не
// нужны §маркеры§ — если они всё же остались после вставки запроса
// из Proxy, при отправке они просто схлопываются в пустые значения
// (см. sendCmd), а не участвуют в переборе.
type repeaterModel struct {
request textarea.Model
response viewport.Model
focus int // 0 — запрос, 1 — ответ
scheme string // "https" | "http" — BuildRequest по умолчанию всегда ставит https
insecure bool
sending bool
sentCount int
lastDuration time.Duration
lastStatus string
errMsg string
statusMsg string // результат последнего ctrl+p ("сохранено в проект" и т.п.)
lastSentRaw string // содержимое request на момент последнего ctrl+r — для звёздочки на вкладке и для ctrl+d diff
// lastResponseRaw/lastStatusCode — последний РЕАЛЬНЫЙ ответ сервера,
// отдельно от того, что сейчас показывает m.response.View(): ctrl+d
// временно подменяет содержимое viewport на diff, а сохранение
// в проект (ctrl+p) должно брать настоящий ответ, а не diff-текст.
lastResponseRaw string
lastStatusCode int
}
func newRepeater() repeaterModel {
ta := textarea.New()
ta.Placeholder = "GET /path HTTP/1.1\nHost: example.com\n\n"
ta.ShowLineNumbers = true
ta.Focus()
return repeaterModel{
request: ta,
response: viewport.New(0, 0),
scheme: "https",
}
}
// SetSize делит доступную высоту пополам между окном запроса и ответа.
func (m *repeaterModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
reqHeight := height / 2
m.request.SetWidth(width)
m.request.SetHeight(reqHeight)
m.response.Width = width
// 8 фиксированных строк вокруг response-viewport (построчный подсчёт
// по View(): "Запрос"-метка 1, разделитель 1, строка статуса 1,
// разделитель+"Ответ"-метка 2, разделитель+футер(две строки) 3 —
// итого 8). Раньше стояло -2 — критически недосчитано с самого
// начала, безотносительно разбиения футера.
m.response.Height = height - reqHeight - 8
}
func (m repeaterModel) Update(msg tea.Msg) (repeaterModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 2
if m.focus == 0 {
m.request.Focus()
} else {
m.request.Blur()
}
return m, nil
case "ctrl+h":
if m.scheme == "https" {
m.scheme = "http"
} else {
m.scheme = "https"
}
return m, nil
case "ctrl+t":
m.insecure = !m.insecure
return m, nil
case "ctrl+r":
m.errMsg = ""
m.sending = true
m.lastSentRaw = m.request.Value()
return m, m.sendCmd()
case "ctrl+d":
m.showDiff()
return m, nil
case "ctrl+p":
return m, m.saveToProjectCmd()
}
}
var cmd tea.Cmd
if m.focus == 0 {
m.request, cmd = m.request.Update(msg)
} else {
m.response, cmd = m.response.Update(msg)
}
return m, cmd
}
// showDiff (ctrl+d) сравнивает текущий черновик с последней реально
// отправленной версией через тот же токенный LCS-diff, что и на вкладке
// Comparer (renderDiffChunks там же, в comparerview.go — общий пакет tui,
// импорт не нужен). Результат временно занимает окно ответа; следующий
// реальный ответ (repeaterRespMsg) его естественным образом перезапишет,
// поэтому отдельного "режима" держать не нужно.
func (m *repeaterModel) showDiff() {
if m.lastSentRaw == "" {
m.errMsg = "ещё не было ни одной отправки — сравнивать не с чем"
return
}
chunks := comparer.Diff(m.lastSentRaw, m.request.Value())
m.response.SetContent("Diff: последняя отправка → текущий черновик ([-удалено-] / [+добавлено+])\n\n" + renderDiffChunks(chunks))
m.errMsg = ""
}
// IsModified — черновик отличается от последней реально отправленной
// версии? Используется корневой моделью для звёздочки в [f4].
func (m repeaterModel) IsModified() bool {
return m.request.Value() != m.lastSentRaw
}
// saveToProjectCmd (ctrl+p) сохраняет текущий запрос + последний реальный
// ответ в открытый проект. Method/URL достаются переиспользованием того
// же ParseMarkers+BuildRequest, что и sendCmd — вместо ручного парсинга
// сырого текста запроса с нуля.
func (m repeaterModel) saveToProjectCmd() tea.Cmd {
raw := m.request.Value()
respRaw := m.lastResponseRaw
statusCode := m.lastStatusCode
return func() tea.Msg {
method, urlStr := "", ""
if clean, points, err := engine.ParseMarkers(raw); err == nil {
payloads := make([]string, len(points))
if req, err2 := engine.BuildRequest(clean, points, payloads); err2 == nil {
method = req.Method
urlStr = req.URL.String()
}
}
host := ""
if u, err := neturl.Parse(urlStr); err == nil {
host = u.Host
}
rec := project.Record{
Source: "repeater",
Host: host,
Method: method,
URL: urlStr,
Status: statusCode,
ContentLength: int64(len(respRaw)),
RawRequest: raw,
RawResponse: respRaw,
}
return saveToProjectMsg{origin: viewRepeater, records: []project.Record{rec}}
}
}
// sendCmd отправляет запрос как есть, без фаззинга: ParseMarkers +
// BuildRequest с пустыми payload'ами переиспользуют тот же парсер сырого
// HTTP, что и Intruder-движок, просто без перебора значений.
func (m repeaterModel) sendCmd() tea.Cmd {
raw := m.request.Value()
scheme := m.scheme
insecure := m.insecure
return func() tea.Msg {
clean, points, err := engine.ParseMarkers(raw)
if err != nil {
return repeaterErrMsg{fmt.Errorf("маркеры §...§ (в Repeater не обязательны): %w", err)}
}
payloads := make([]string, len(points))
req, err := engine.BuildRequest(clean, points, payloads)
if err != nil {
return repeaterErrMsg{err}
}
req.URL.Scheme = scheme
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
},
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return repeaterErrMsg{err}
}
defer resp.Body.Close()
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
return repeaterErrMsg{fmt.Errorf("чтение ответа: %w", err)}
}
return repeaterRespMsg{
response: string(dump),
status: resp.Status,
statusCode: resp.StatusCode,
duration: time.Since(start),
}
}
}
func (m repeaterModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
status := fmt.Sprintf("Схема: %s (ctrl+h) · Insecure TLS: %v (ctrl+t) · отправлено: %d", m.scheme, m.insecure, m.sentCount)
if m.sending {
status += " · отправка…"
}
if m.lastStatus != "" {
status += fmt.Sprintf(" · последний ответ: %s за %s", m.lastStatus, m.lastDuration.Round(time.Millisecond))
}
var b strings.Builder
b.WriteString(focusMark(0) + "Запрос\n")
b.WriteString(m.request.View())
b.WriteString("\n\n")
b.WriteString(status + "\n")
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n")
}
if m.statusMsg != "" {
b.WriteString(m.statusMsg + "\n")
}
b.WriteString("\n" + focusMark(1) + "Ответ\n")
b.WriteString(m.response.View())
b.WriteString("\n\ntab — между окнами · ctrl+r — отправить · ctrl+d — diff · ctrl+p — сохранить в проект\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}

View file

@ -0,0 +1,441 @@
package tui
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/cli"
"github.com/r3g1tpr0cs/burterm/internal/engine"
"github.com/r3g1tpr0cs/burterm/internal/payload"
)
// requestBuilderModel — форма ввода: сырой HTTP-запрос с §маркерами§,
// режим атаки, спецификации payload'ов (по одной на точку вставки,
// в порядке §маркеров§) и параметры движка.
type requestBuilderModel struct {
textarea textarea.Model
mode engine.AttackMode
payloadInputs []textinput.Model
concurrency textinput.Model
rateLimitMs textinput.Model
timeoutSec textinput.Model
insecure bool
scheme string // "https" | "http" — см. комментарий к engine.Config.ForceScheme
// focusIdx: 0 — textarea, 1..len(payloadInputs) — поля payload'ов,
// затем concurrency, rateLimitMs, timeoutSec.
focusIdx int
errMsg string
statusMsg string // информационная строка (не ошибка) — напр. что предложила ctrl+n/ctrl+l
presetCycleIdx int // текущая позиция обзора заготовок по ctrl+l
lastSentRaw string // содержимое textarea на момент последнего ctrl+r — для звёздочки "есть несохранённые правки"
// recursionEnabled — включает авто-рекурсию в найденные директории
// (аналог -recursion у ffuf, см. комментарий у recursionMeta в model.go).
// Работает только когда в запросе ровно один §маркер§ — при нескольких
// точках вставки неясно, по какой из них углубляться, а городить ради
// этого отдельный UI-селектор точки не стали ради простоты.
recursionEnabled bool
}
// defaultRecursionStatuses/MaxDepth — параметры рекурсии не вынесены
// в отдельные поля формы (и так уже 9+ полей на экране) — фиксированные
// разумные дефолты, как первое приближение. Статусы совпадают с тем,
// что ffuf по умолчанию считает "нашли что-то, стоит углубиться".
const (
defaultRecursionStatuses = "200,301,302,403"
defaultRecursionMaxDepth = 2
)
func newRequestBuilder() requestBuilderModel {
ta := textarea.New()
ta.Placeholder = "GET /api/user?id=§1§ HTTP/1.1\nHost: example.com\n\n"
ta.ShowLineNumbers = true
ta.Focus()
payloadIn := textinput.New()
payloadIn.Placeholder = "wordlist:путь | range:1:100:1 | preset:numbers-small | ctrl+n/ctrl+l"
payloadIn.CharLimit = 256
concurrency := textinput.New()
concurrency.Placeholder = "10"
concurrency.CharLimit = 6
rateLimit := textinput.New()
rateLimit.Placeholder = "0"
rateLimit.CharLimit = 8
timeout := textinput.New()
timeout.Placeholder = "10"
timeout.CharLimit = 6
return requestBuilderModel{
textarea: ta,
mode: engine.ModeSniper,
scheme: "https",
payloadInputs: []textinput.Model{payloadIn},
concurrency: concurrency,
rateLimitMs: rateLimit,
timeoutSec: timeout,
}
}
// totalFocusable — сколько полей можно обойти табом: textarea +
// поля payload'ов + concurrency/rateLimit/timeout.
func (m *requestBuilderModel) totalFocusable() int {
return 1 + len(m.payloadInputs) + 3
}
// applyFocus снимает фокус со всех полей и ставит его на m.focusIdx.
// Возвращаемые Focus() команды (мигание курсора) осознанно отбрасываются
// ради простоты — не влияет на функциональность, только на анимацию.
func (m *requestBuilderModel) applyFocus() {
m.textarea.Blur()
for i := range m.payloadInputs {
m.payloadInputs[i].Blur()
}
m.concurrency.Blur()
m.rateLimitMs.Blur()
m.timeoutSec.Blur()
switch {
case m.focusIdx == 0:
m.textarea.Focus()
case m.focusIdx <= len(m.payloadInputs):
m.payloadInputs[m.focusIdx-1].Focus()
case m.focusIdx == len(m.payloadInputs)+1:
m.concurrency.Focus()
case m.focusIdx == len(m.payloadInputs)+2:
m.rateLimitMs.Focus()
default:
m.timeoutSec.Focus()
}
}
// IsModified — правки в textarea после последней отправки (ctrl+r) есть?
// Используется корневой моделью для звёздочки в названии вкладки [f1].
// Сравнение с изначально пустым lastSentRaw тоже корректно: если текст
// набран, но ни разу не отправлен, это и есть несохранённые правки.
func (m requestBuilderModel) IsModified() bool {
return m.textarea.Value() != m.lastSentRaw
}
func modeName(mode engine.AttackMode) string {
switch mode {
case engine.ModeSniper:
return "sniper"
case engine.ModeBattering:
return "battering"
case engine.ModePitchfork:
return "pitchfork"
case engine.ModeClusterBomb:
return "clusterbomb"
default:
return "?"
}
}
func nextMode(mode engine.AttackMode) engine.AttackMode {
return (mode + 1) % 4
}
func (m requestBuilderModel) Update(msg tea.Msg) (requestBuilderModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focusIdx = (m.focusIdx + 1) % m.totalFocusable()
m.applyFocus()
return m, nil
case "shift+tab":
m.focusIdx--
if m.focusIdx < 0 {
m.focusIdx = m.totalFocusable() - 1
}
m.applyFocus()
return m, nil
case "ctrl+g":
// смена режима атаки; ctrl+m конфликтует с Enter в некоторых
// терминалах, поэтому используем ctrl+g ("go mode")
m.mode = nextMode(m.mode)
return m, nil
case "ctrl+t":
m.insecure = !m.insecure
return m, nil
case "ctrl+h":
if m.scheme == "https" {
m.scheme = "http"
} else {
m.scheme = "https"
}
return m, nil
case "ctrl+p":
ti := textinput.New()
ti.Placeholder = "wordlist:путь | range:1:100:1 | preset:numbers-small | ctrl+n/ctrl+l"
ti.CharLimit = 256
m.payloadInputs = append(m.payloadInputs, ti)
return m, nil
case "ctrl+x":
if len(m.payloadInputs) > 1 {
m.payloadInputs = m.payloadInputs[:len(m.payloadInputs)-1]
if m.focusIdx >= m.totalFocusable() {
m.focusIdx = m.totalFocusable() - 1
}
m.applyFocus()
}
return m, nil
case "ctrl+r":
m.errMsg = ""
m.lastSentRaw = m.textarea.Value()
return m, m.runCmd()
case "ctrl+n":
m.suggestPreset()
return m, nil
case "ctrl+l":
m.cyclePreset()
return m, nil
case "ctrl+u":
m.recursionEnabled = !m.recursionEnabled
return m, nil
}
}
var cmd tea.Cmd
switch {
case m.focusIdx == 0:
m.textarea, cmd = m.textarea.Update(msg)
case m.focusIdx <= len(m.payloadInputs):
m.payloadInputs[m.focusIdx-1], cmd = m.payloadInputs[m.focusIdx-1].Update(msg)
case m.focusIdx == len(m.payloadInputs)+1:
m.concurrency, cmd = m.concurrency.Update(msg)
case m.focusIdx == len(m.payloadInputs)+2:
m.rateLimitMs, cmd = m.rateLimitMs.Update(msg)
default:
m.timeoutSec, cmd = m.timeoutSec.Update(msg)
}
return m, cmd
}
// currentPayloadPoint возвращает индекс точки вставки (0-based),
// соответствующей полю payload'а в фокусе, и true — если фокус сейчас
// действительно на одном из полей payload'ов (а не на textarea или
// concurrency/rateLimit/timeout).
func (m *requestBuilderModel) currentPayloadPoint() (int, bool) {
if m.focusIdx < 1 || m.focusIdx > len(m.payloadInputs) {
return 0, false
}
return m.focusIdx - 1, true
}
// suggestPreset (ctrl+n) определяет имя параметра рядом с §маркером§,
// на который смотрит поле payload'а в фокусе, и подставляет туда лучшую
// по эвристике заготовку из payload.SmartSuggest. Остальные варианты
// показывает в statusMsg — это подсказка, а не единственно верный ответ,
// пользователь всегда может переопределить вручную.
func (m *requestBuilderModel) suggestPreset() {
point, ok := m.currentPayloadPoint()
if !ok {
m.statusMsg = "ctrl+n работает, когда в фокусе поле payload'а (не запрос и не параметры движка)"
return
}
paramName := engine.DetectParamName(m.textarea.Value(), point)
suggestions := payload.SmartSuggest(paramName)
if len(suggestions) == 0 {
m.statusMsg = "не удалось предложить заготовку для этой точки"
return
}
m.payloadInputs[point].SetValue("preset:" + suggestions[0])
label := paramName
if label == "" {
label = "(имя параметра не распознано)"
}
if len(suggestions) > 1 {
m.statusMsg = fmt.Sprintf("Подсказка для %q: preset:%s (ещё варианты: %s)", label, suggestions[0], strings.Join(suggestions[1:], ", "))
} else {
m.statusMsg = fmt.Sprintf("Подсказка для %q: preset:%s", label, suggestions[0])
}
}
// cyclePreset (ctrl+l) обходит все встроенные заготовки по кругу,
// независимо от контекста — для случаев, когда хочется вручную
// пролистать варианты (числа, буквы и т.д.), а не полагаться на
// эвристику suggestPreset.
func (m *requestBuilderModel) cyclePreset() {
point, ok := m.currentPayloadPoint()
if !ok {
m.statusMsg = "ctrl+l работает, когда в фокусе поле payload'а (не запрос и не параметры движка)"
return
}
names := payload.PresetNames()
if len(names) == 0 {
return
}
name := names[m.presetCycleIdx%len(names)]
m.presetCycleIdx++
m.payloadInputs[point].SetValue("preset:" + name)
m.statusMsg = fmt.Sprintf("%s — %s", name, payload.PresetDescription(name))
}
// runCmd снимает копию всех значений формы (чтобы не держать ссылку на
// изменяемую модель внутри замыкания команды), валидирует их и запускает
// атаку. Возвращает attackStartedMsg с каналом результатов и cancel'ом,
// либо errMsg с человекочитаемой причиной отказа.
func (m requestBuilderModel) runCmd() tea.Cmd {
raw := m.textarea.Value()
mode := m.mode
insecure := m.insecure
scheme := m.scheme
recursionEnabled := m.recursionEnabled
specs := make([]string, len(m.payloadInputs))
for i, ti := range m.payloadInputs {
specs[i] = ti.Value()
}
concurrencyStr := m.concurrency.Value()
rateLimitStr := m.rateLimitMs.Value()
timeoutStr := m.timeoutSec.Value()
return func() tea.Msg {
clean, points, err := engine.ParseMarkers(raw)
if err != nil {
return errMsg{fmt.Errorf("маркеры §...§: %w", err)}
}
if len(points) == 0 {
return errMsg{fmt.Errorf("в запросе нет ни одного §маркера§ — нечего фаззить")}
}
gens := make([]payload.Generator, 0, len(specs))
for _, spec := range specs {
if spec == "" {
continue
}
g, err := cli.ParsePayloadSpec(spec)
if err != nil {
return errMsg{fmt.Errorf("payload %q: %w", spec, err)}
}
gens = append(gens, g)
}
if len(gens) == 0 {
return errMsg{fmt.Errorf("укажи хотя бы один payload (ctrl+p — добавить поле)")}
}
concurrency, _ := strconv.Atoi(concurrencyStr)
if concurrency <= 0 {
concurrency = 10
}
rateLimitVal, _ := strconv.Atoi(rateLimitStr)
timeoutVal, _ := strconv.Atoi(timeoutStr)
if timeoutVal <= 0 {
timeoutVal = 10
}
rateLimit := time.Duration(rateLimitVal) * time.Millisecond
timeout := time.Duration(timeoutVal) * time.Second
eng := engine.New(engine.Config{
Concurrency: concurrency,
RateLimit: rateLimit,
Timeout: timeout,
InsecureSkipVerify: insecure,
ForceScheme: scheme,
})
base := make([]string, len(points))
total := engine.EstimateTotal(mode, points, gens)
ctx, cancel := context.WithCancel(context.Background())
ch, err := eng.Run(ctx, mode, clean, points, gens, base)
if err != nil {
cancel()
return errMsg{err}
}
var recursion *recursionMeta
if recursionEnabled && len(points) == 1 {
statusPred, _ := engine.ParseStatusFilter(defaultRecursionStatuses) // синтаксис уже проверен константой, ошибки не будет
recursion = &recursionMeta{
rawTemplate: raw,
pointIndex: 0,
mode: mode,
specs: specs,
depth: 0,
maxDepth: defaultRecursionMaxDepth,
statusPred: statusPred,
concurrency: concurrency,
rateLimit: rateLimit,
timeout: timeout,
insecure: insecure,
forceScheme: scheme,
}
}
return attackStartedMsg{ch: ch, cancel: cancel, total: total, recursion: recursion}
}
}
func (m requestBuilderModel) View() string {
var b strings.Builder
b.WriteString(fieldLabel("Запрос (§...§ отмечает точку вставки)", m.focusIdx == 0))
b.WriteString(m.textarea.View())
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("Режим: %-11s (ctrl+g) Схема: %-5s (ctrl+h) Insecure TLS: %v (ctrl+t)\n", modeName(m.mode), m.scheme, m.insecure))
b.WriteString(fmt.Sprintf("Рекурсия в директории: %v (ctrl+u; работает только с одним §маркером§, статусы %s, глубина %d)\n\n", m.recursionEnabled, defaultRecursionStatuses, defaultRecursionMaxDepth))
b.WriteString("Payload'ы, по одному на §маркер§ (ctrl+p — добавить, ctrl+x — убрать):\n")
for i, ti := range m.payloadInputs {
b.WriteString(fieldLabel(fmt.Sprintf(" #%d", i+1), m.focusIdx == i+1))
b.WriteString(ti.View())
b.WriteString("\n")
}
b.WriteString("\n")
idxConcurrency := len(m.payloadInputs) + 1
idxRateLimit := len(m.payloadInputs) + 2
idxTimeout := len(m.payloadInputs) + 3
b.WriteString(fieldLabel("Concurrency", m.focusIdx == idxConcurrency))
b.WriteString(m.concurrency.View())
b.WriteString("\n")
b.WriteString(fieldLabel("Rate limit, мс", m.focusIdx == idxRateLimit))
b.WriteString(m.rateLimitMs.View())
b.WriteString("\n")
b.WriteString(fieldLabel("Timeout, с", m.focusIdx == idxTimeout))
b.WriteString(m.timeoutSec.View())
b.WriteString("\n\n")
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n\n")
}
if m.statusMsg != "" {
b.WriteString(m.statusMsg + "\n\n")
}
b.WriteString("tab/shift+tab — между полями · ctrl+r — атака · ctrl+n — подсказать заготовку\n")
b.WriteString("ctrl+l — обзор заготовок · ctrl+u — рекурсия · f2 — результаты · ctrl+c — выход\n")
return b.String()
}
// fieldLabel помечает подпись поля маркером «▸», если оно сейчас в фокусе.
func fieldLabel(name string, focused bool) string {
marker := " "
if focused {
marker = "▸ "
}
return marker + name + "\n"
}

497
internal/tui/results.go Normal file
View file

@ -0,0 +1,497 @@
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()
}

447
internal/tui/scannerview.go Normal file
View file

@ -0,0 +1,447 @@
package tui
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"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"
"github.com/r3g1tpr0cs/burterm/internal/scanner"
)
// scannerModel — вкладка Scanner: запрос с §маркером§ (сканируется ровно
// одна точка вставки за прогон — см. pointIdx), чеклист активных
// проверок, отдельный блок JWT (не требует запроса вообще — декодирует
// вставленный токен) и поток находок в общем viewport.
type scannerModel struct {
textarea textarea.Model
pointIdx textinput.Model
checkSelected map[string]bool
checkCursor int
jwtInput textinput.Model
insecure bool
running bool
count int
lines []string
viewport viewport.Model
errMsg string
statusMsg string
progressDone int
progressTotal int
// focus: 0 — запрос, 1 — индекс точки, 2 — список проверок,
// 3 — поле JWT, 4 — viewport находок (скролл).
focus int
}
func newScannerView() scannerModel {
ta := textarea.New()
ta.Placeholder = "GET /search?q=§1§ HTTP/1.1\nHost: example.com\n\n"
ta.ShowLineNumbers = true
ta.Focus()
pointIn := textinput.New()
pointIn.Placeholder = "0"
pointIn.CharLimit = 4
jwtIn := textinput.New()
jwtIn.Placeholder = "вставь JWT: ctrl+d — декодировать, ctrl+f — forge alg:none"
jwtIn.CharLimit = 4096
selected := make(map[string]bool)
for _, name := range scanner.AllCheckNames() {
selected[name] = true // по умолчанию включены все — можно выключить лишние через space
}
return scannerModel{
textarea: ta,
pointIdx: pointIn,
checkSelected: selected,
jwtInput: jwtIn,
viewport: viewport.New(0, 0),
}
}
func (m *scannerModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
taHeight := height / 4
if taHeight < 3 {
taHeight = 3
}
m.textarea.SetWidth(width)
m.textarea.SetHeight(taHeight)
m.viewport.Width = width
// 15 фиксированных строк + по одной на каждую проверку в списке
// (построчный подсчёт по View(): "Запрос"-метка 1, разделитель 1,
// строка "Индекс точки/Insecure" 1 + разделитель 1, "Проверки"-метка
// 1, список проверок — по строке на каждую (динамически, отдельное
// слагаемое len(AllCheckNames())), разделитель 1, "JWT"-метка 1,
// поле JWT 1, разделитель 1, строка статуса 1, разделитель перед
// viewport 1, разделитель+футер(две строки) 3 — итого 15 без учёта
// списка проверок. Раньше стояло -12-K — недосчитано на 3 строки ещё
// до разбиения футера.
vh := height - taHeight - 15 - len(scanner.AllCheckNames())
if vh < 3 {
vh = 3
}
m.viewport.Height = vh
}
// Reset очищает находки и прогресс перед новым запуском сканирования.
func (m *scannerModel) Reset() {
m.lines = nil
m.count = 0
m.running = true
m.progressDone = 0
m.progressTotal = 0
m.viewport.SetContent("")
}
// Finish помечает сканирование завершённым (канал находок закрылся).
func (m *scannerModel) Finish() {
m.running = false
}
// AddFinding дописывает находку (из активного сканирования, CORS или
// JWT — источник для отображения не важен, всё в одном потоке).
func (m *scannerModel) AddFinding(f scanner.Finding) {
m.count++
m.lines = append(m.lines, formatFinding(m.count, f))
m.viewport.SetContent(strings.Join(m.lines, "\n\n"))
m.viewport.GotoBottom()
}
func formatFinding(n int, f scanner.Finding) string {
sevText := severityStyle(f.Severity).Render(fmt.Sprintf("%-6s", f.Severity.String()))
var b strings.Builder
b.WriteString(fmt.Sprintf("#%-3d %s %s\n", n, sevText, f.Check))
b.WriteString(" " + f.Detail + "\n")
if f.Payload != "" {
b.WriteString(" payload: " + f.Payload + "\n")
}
if f.Evidence != "" {
b.WriteString(" evidence: " + f.Evidence)
}
return strings.TrimRight(b.String(), "\n")
}
func (m *scannerModel) selectedCheckNames() []string {
var out []string
for _, name := range scanner.AllCheckNames() {
if m.checkSelected[name] {
out = append(out, name)
}
}
return out
}
func (m scannerModel) Update(msg tea.Msg) (scannerModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 5
m.textarea.Blur()
m.pointIdx.Blur()
m.jwtInput.Blur()
switch m.focus {
case 0:
m.textarea.Focus()
case 1:
m.pointIdx.Focus()
case 3:
m.jwtInput.Focus()
}
return m, nil
case "up":
if m.focus == 2 && m.checkCursor > 0 {
m.checkCursor--
}
return m, nil
case "down":
names := scanner.AllCheckNames()
if m.focus == 2 && m.checkCursor < len(names)-1 {
m.checkCursor++
}
return m, nil
case " ", "space":
if m.focus == 2 {
names := scanner.AllCheckNames()
if m.checkCursor >= 0 && m.checkCursor < len(names) {
name := names[m.checkCursor]
m.checkSelected[name] = !m.checkSelected[name]
}
return m, nil
}
case "ctrl+t":
m.insecure = !m.insecure
return m, nil
case "ctrl+r":
if m.running {
return m, nil
}
m.errMsg = ""
m.statusMsg = ""
return m, m.runCmd()
case "ctrl+o":
m.errMsg = ""
m.statusMsg = ""
return m, m.corsCmd()
case "ctrl+d":
m.decodeJWT()
return m, nil
case "ctrl+f":
m.forgeJWT()
return m, nil
case "ctrl+p":
return m, m.saveToProjectCmd()
}
}
var cmd tea.Cmd
switch m.focus {
case 0:
m.textarea, cmd = m.textarea.Update(msg)
case 1:
m.pointIdx, cmd = m.pointIdx.Update(msg)
case 3:
m.jwtInput, cmd = m.jwtInput.Update(msg)
case 4:
m.viewport, cmd = m.viewport.Update(msg)
}
return m, cmd
}
// decodeJWT (ctrl+d) — синхронная операция без сети: разбор base64/JSON
// достаточно дёшев, чтобы не уходить в tea.Cmd.
func (m *scannerModel) decodeJWT() {
token := strings.TrimSpace(m.jwtInput.Value())
if token == "" {
m.errMsg = "вставь JWT в поле выше"
return
}
info, err := scanner.DecodeJWT(token)
if err != nil {
m.errMsg = err.Error()
return
}
m.errMsg = ""
headerJSON, _ := json.MarshalIndent(info.Header, "", " ")
payloadJSON, _ := json.MarshalIndent(info.Payload, "", " ")
m.AddFinding(scanner.Finding{
Check: "JWT", Severity: scanner.SeverityInfo,
Detail: fmt.Sprintf("Header:\n%s\nPayload:\n%s", headerJSON, payloadJSON),
})
for _, f := range info.Findings {
m.AddFinding(f)
}
}
// forgeJWT (ctrl+f) — тоже синхронно, чистая функция без сети.
func (m *scannerModel) forgeJWT() {
token := strings.TrimSpace(m.jwtInput.Value())
if token == "" {
m.errMsg = "вставь JWT в поле выше"
return
}
variant, err := scanner.AlgNoneVariant(token)
if err != nil {
m.errMsg = err.Error()
return
}
m.errMsg = ""
m.AddFinding(scanner.Finding{
Check: "JWT", Severity: scanner.SeverityInfo,
Detail: "alg:none вариант — вставь вручную в заголовок Authorization через Repeater:\n" + variant,
})
}
// runCmd запускает активное сканирование выбранных проверок против
// одной точки вставки. Возвращает scanStartedMsg с каналом находок
// и cancel'ом, либо scanErrMsg с причиной отказа.
func (m scannerModel) runCmd() tea.Cmd {
raw := m.textarea.Value()
pointStr := strings.TrimSpace(m.pointIdx.Value())
selected := m.selectedCheckNames()
insecure := m.insecure
return func() tea.Msg {
clean, points, err := engine.ParseMarkers(raw)
if err != nil {
return scanErrMsg{fmt.Errorf("маркеры §...§: %w", err)}
}
if len(points) == 0 {
return scanErrMsg{fmt.Errorf("в запросе нет ни одного §маркера§ — нечего сканировать")}
}
if len(selected) == 0 {
return scanErrMsg{fmt.Errorf("выбери хотя бы одну проверку (space на строке в списке)")}
}
pointIdx := 0
if pointStr != "" {
n, err := strconv.Atoi(pointStr)
if err != nil {
return scanErrMsg{fmt.Errorf("некорректный индекс точки: %v", err)}
}
pointIdx = n
}
if pointIdx < 0 || pointIdx >= len(points) {
return scanErrMsg{fmt.Errorf("индекс точки должен быть от 0 до %d, получено %d", len(points)-1, pointIdx)}
}
sc := scanner.New(scanner.Config{Timeout: 15 * time.Second, InsecureSkipVerify: insecure})
base := make([]string, len(points))
ctx, cancel := context.WithCancel(context.Background())
ch, err := sc.Run(ctx, clean, points, pointIdx, base, selected)
if err != nil {
cancel()
return scanErrMsg{err}
}
return scanStartedMsg{ch: ch, cancel: cancel}
}
}
// saveToProjectCmd (ctrl+p) сохраняет сканируемый запрос вместе со всеми
// накопленными находками одной записью — Finding сам по себе не пара
// запрос/ответ, а список аномалий по нескольким пробникам сразу, так
// что естественная единица сохранения здесь — "прогон целиком", а не
// находка по отдельности. Method/URL — тем же способом, что и в
// Repeater: переиспользуем ParseMarkers+BuildRequest вместо ручного
// парсинга сырого текста.
func (m scannerModel) saveToProjectCmd() tea.Cmd {
raw := m.textarea.Value()
findingsText := strings.Join(m.lines, "\n\n")
return func() tea.Msg {
if findingsText == "" {
return projectErrMsg{fmt.Errorf("нет находок для сохранения — сначала запусти сканирование (ctrl+r)")}
}
method, urlStr := "", ""
if clean, points, err := engine.ParseMarkers(raw); err == nil {
payloads := make([]string, len(points))
if req, err2 := engine.BuildRequest(clean, points, payloads); err2 == nil {
method = req.Method
urlStr = req.URL.String()
}
}
host := ""
if u, err := neturl.Parse(urlStr); err == nil {
host = u.Host
}
rec := project.Record{
Source: "scanner",
Host: host,
Method: method,
URL: urlStr,
RawRequest: raw,
RawResponse: findingsText,
}
return saveToProjectMsg{origin: viewScanner, records: []project.Record{rec}}
}
}
// corsCmd (ctrl+o) — одноразовая CORS-проверка всего запроса, без
// привязки к точке вставки (может работать даже без §маркеров§ вовсе).
func (m scannerModel) corsCmd() tea.Cmd {
raw := m.textarea.Value()
insecure := m.insecure
return func() tea.Msg {
clean, points, err := engine.ParseMarkers(raw)
if err != nil {
return scanErrMsg{fmt.Errorf("маркеры §...§: %w", err)}
}
base := make([]string, len(points))
req, err := engine.BuildRequest(clean, points, base)
if err != nil {
return scanErrMsg{err}
}
client := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}},
}
finding, err := scanner.CheckCORS(client, req)
if err != nil {
return scanErrMsg{fmt.Errorf("CORS-проверка: %w", err)}
}
return corsResultMsg{finding: finding}
}
}
func (m scannerModel) 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.textarea.View())
b.WriteString("\n\n")
b.WriteString(focusMark(1) + "Индекс точки (0-based, по умолчанию 0): ")
b.WriteString(m.pointIdx.View())
b.WriteString(fmt.Sprintf(" Insecure TLS: %v (ctrl+t)\n\n", m.insecure))
b.WriteString(focusMark(2) + "Проверки (space — включить/выключить):\n")
for i, name := range scanner.AllCheckNames() {
mark := "[ ]"
if m.checkSelected[name] {
mark = "[x]"
}
cursor := " "
if m.focus == 2 && i == m.checkCursor {
cursor = "▸ "
}
b.WriteString(fmt.Sprintf("%s%s %s\n", cursor, mark, name))
}
b.WriteString("\n")
b.WriteString(focusMark(3) + "JWT (ctrl+d — декодировать, ctrl+f — forge alg:none)\n")
b.WriteString(m.jwtInput.View())
b.WriteString("\n\n")
status := "готов"
if m.running {
status = fmt.Sprintf("сканирование… %d%s (esc — остановить)", m.progressDone, progressSuffix(m.progressDone, m.progressTotal))
}
b.WriteString(fmt.Sprintf("Статус: %s · находок: %d\n", status, m.count))
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n")
}
if m.statusMsg != "" {
b.WriteString(m.statusMsg + "\n")
}
b.WriteString("\n" + focusMark(4) + m.viewport.View())
b.WriteString("\n\ntab — фокус · ctrl+r — сканировать · ctrl+o — CORS · ctrl+p — сохранить в проект\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}

View file

@ -0,0 +1,160 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/sequencer"
)
// sequencerModel — вкладка Sequencer: textarea с токенами (по одному на
// строку), запуск FIPS-анализа по ctrl+r и читаемый отчёт по каждому
// проанализированному 20 000-битному блоку.
type sequencerModel struct {
input textarea.Model
output viewport.Model
focus int // 0 — вход, 1 — выход (скролл)
errMsg string
}
func newSequencerView() sequencerModel {
ta := textarea.New()
ta.Placeholder = "Токены по одному на строку — либо hex-строки, либо сырой текст"
ta.ShowLineNumbers = true
ta.Focus()
return sequencerModel{
input: ta,
output: viewport.New(0, 0),
}
}
func (m *sequencerModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
inHeight := height / 2
m.input.SetWidth(width)
m.input.SetHeight(inHeight)
m.output.Width = width
// 6 фиксированных строк вокруг output-viewport (построчный подсчёт по
// View(): "Токены"-метка 1, разделитель 1, "Отчёт FIPS"-метка 1,
// разделитель+футер(две строки) 3 — итого 6). Раньше стояло -4 — не
// совпадало с View() ещё до того, как футер стал двухстрочным.
m.output.Height = height - inHeight - 6
}
func (m sequencerModel) Update(msg tea.Msg) (sequencerModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 2
if m.focus == 0 {
m.input.Focus()
} else {
m.input.Blur()
}
return m, nil
case "ctrl+r":
m.analyze()
return m, nil
}
}
var cmd tea.Cmd
if m.focus == 0 {
m.input, cmd = m.input.Update(msg)
} else {
m.output, cmd = m.output.Update(msg)
}
return m, cmd
}
func (m *sequencerModel) analyze() {
var samples [][]byte
for _, line := range strings.Split(m.input.Value(), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
samples = append(samples, sequencer.ParseSample(line))
}
if len(samples) == 0 {
m.errMsg = "нет сэмплов — вставь токены, по одному на строку"
return
}
results, blocks, bitsAvailable, err := sequencer.AnalyzeConcatenated(samples)
if err != nil {
m.errMsg = err.Error()
m.output.SetContent(fmt.Sprintf("Сэмплов: %d, доступно бит: %d — недостаточно для анализа.", len(samples), bitsAvailable))
return
}
m.errMsg = ""
m.output.SetContent(renderSequencerReport(samples, results, blocks, bitsAvailable))
}
func renderSequencerReport(samples [][]byte, results []sequencer.BlockResult, blocks, bitsAvailable int) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("Сэмплов: %d · доступно бит: %d · проанализировано блоков по 20000 бит: %d\n\n", len(samples), bitsAvailable, blocks))
passCount := 0
for _, r := range results {
if r.AllPass() {
passCount++
}
}
b.WriteString(fmt.Sprintf("Итог: %d/%d блоков прошли все четыре теста\n\n", passCount, blocks))
for i, r := range results {
b.WriteString(fmt.Sprintf("Блок %d:\n", i+1))
b.WriteString(fmt.Sprintf(" Monobit: единиц=%d %s\n", r.MonobitOnes, passLabel(r.MonobitPass)))
b.WriteString(fmt.Sprintf(" Poker: X=%.2f %s\n", r.PokerX, passLabel(r.PokerPass)))
b.WriteString(fmt.Sprintf(" Runs: %s\n", passLabel(r.RunsPass)))
b.WriteString(fmt.Sprintf(" Long run: макс. серия=%d бит %s\n", r.LongRunMax, passLabel(r.LongRunPass)))
b.WriteString("\n")
}
return b.String()
}
func passLabel(pass bool) string {
if pass {
return "PASS"
}
return "FAIL"
}
func (m sequencerModel) 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.input.View())
b.WriteString("\n\n")
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n\n")
}
b.WriteString(focusMark(1) + "Отчёт FIPS 140-2\n")
b.WriteString(m.output.View())
b.WriteString("\n\ntab — между полями · ctrl+r — запустить анализ\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}

230
internal/tui/spiderview.go Normal file
View file

@ -0,0 +1,230 @@
package tui
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"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/proxy"
"github.com/r3g1tpr0cs/burterm/internal/spider"
)
// spiderModel — вкладка краулера: два поля ввода (стартовый URL, глубина)
// и прокручиваемый лог найденных страниц/форм, пополняемый по мере
// поступления из потока spider.Page. Одновременно поддерживается только
// один активный обход — в отличие от Intruder-атак, тут это осознанное
// упрощение (краулинг одного сайта редко имеет смысл дублировать).
//
// В отличие от Intruder/Scanner, у краулинга нет заранее известного
// общего числа страниц (сайт не размечен, пока его не обойдёшь) — поэтому
// вместо N/Total здесь просто анимированный спиннер, честно говорящий
// "процесс идёт", а не выдуманный процент.
type spiderModel struct {
startURL textinput.Model
maxDepth textinput.Model
viewport viewport.Model
spinner spinner.Model
pages []spider.Page
count int
running bool
errMsg string
// sessionEntries — зеркало истории Proxy, обновляется тем же
// proxyEntryMsg, что и proxyView/targetView (см. model.go). Нужно
// только для авто-обнаружения Cookie/Authorization перед стартом
// обхода — сама вкладка их не показывает и не редактирует.
sessionEntries []proxy.Entry
sessionDetected bool // сессия была подхвачена автоматически при последнем запуске
focus int // 0 — startURL, 1 — maxDepth
}
func newSpiderView() spiderModel {
su := textinput.New()
su.Placeholder = "https://example.com"
su.CharLimit = 512
su.Focus()
md := textinput.New()
md.Placeholder = "3"
md.CharLimit = 3
sp := spinner.New()
sp.Spinner = spinner.Dot
return spiderModel{
startURL: su,
maxDepth: md,
viewport: viewport.New(0, 0),
spinner: sp,
}
}
func (m *spiderModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
m.viewport.Width = width
// 11 фиксированных строк (построчный подсчёт: "Стартовый URL"-метка+
// поле+разделитель (3), "Макс. глубина"-метка+поле+разделитель (3),
// строка статуса (1), разделитель перед viewport (1), разделитель+
// футер(две строки) (3) — итого 11). Раньше стояло -6 — недосчитано
// с самого начала, безотносительно разбиения футера.
vh := height - 11
if vh < 0 {
vh = 0
}
m.viewport.Height = vh
}
// AddPage дописывает найденную страницу (и её формы) в лог.
func (m *spiderModel) AddPage(p spider.Page) {
m.pages = append(m.pages, p)
m.count++
m.refresh()
m.viewport.GotoBottom()
}
// AddProxyEntry зеркалит перехваченный запрос из Proxy — используется
// только для последующего извлечения сессионных заголовков, на экране
// не отображается.
func (m *spiderModel) AddProxyEntry(e proxy.Entry) {
m.sessionEntries = append(m.sessionEntries, e)
}
func (m *spiderModel) Finish() {
m.running = false
}
func (m *spiderModel) refresh() {
var b strings.Builder
for i, p := range m.pages {
b.WriteString(fmt.Sprintf("#%-4d %-3d %s\n", i+1, p.Status, p.URL))
for _, f := range p.Forms {
fields := strings.Join(f.Fields, ", ")
b.WriteString(fmt.Sprintf(" форма %s %s (поля: %s)\n", f.Method, f.Action, fields))
}
}
m.viewport.SetContent(b.String())
}
func (m spiderModel) Update(msg tea.Msg) (spiderModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 2
if m.focus == 0 {
m.startURL.Focus()
m.maxDepth.Blur()
} else {
m.maxDepth.Focus()
m.startURL.Blur()
}
return m, nil
case "ctrl+r":
if m.running {
// Обход уже идёт — второй запуск проигнорирован, чтобы не
// плодить параллельные краулеры по одному и тому же scope
// с общим (нулевым) состоянием visited.
return m, nil
}
m.errMsg = ""
return m, m.startCmd()
}
}
var fieldCmd tea.Cmd
if m.focus == 0 {
m.startURL, fieldCmd = m.startURL.Update(msg)
} else {
m.maxDepth, fieldCmd = m.maxDepth.Update(msg)
}
var vpCmd tea.Cmd
m.viewport, vpCmd = m.viewport.Update(msg)
return m, tea.Batch(fieldCmd, vpCmd)
}
// startCmd валидирует URL и глубину, создаёт Spider (с авто-подхваченными
// сессионными заголовками под целевой хост, если такие нашлись в истории
// Proxy) и запускает обход. Возвращает spiderStartedMsg с каналом страниц
// и cancel'ом, либо spiderErrMsg с причиной отказа.
func (m spiderModel) startCmd() tea.Cmd {
rawURL := strings.TrimSpace(m.startURL.Value())
depthStr := m.maxDepth.Value()
sessionEntries := m.sessionEntries // срез читается только на чтение в горутине — без гонки
return func() tea.Msg {
if rawURL == "" {
return spiderErrMsg{fmt.Errorf("укажи стартовый URL")}
}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return spiderErrMsg{fmt.Errorf("нужен полный URL со схемой, например https://example.com")}
}
depth, _ := strconv.Atoi(depthStr)
if depth <= 0 {
depth = 3
}
sp := spider.New(u, depth)
sessionHeaders := proxy.ExtractSessionHeaders(sessionEntries, u.Host)
sessionDetected := len(sessionHeaders) > 0
if sessionDetected {
sp.SetHeaders(sessionHeaders)
}
ctx, cancel := context.WithCancel(context.Background())
ch := sp.Crawl(ctx, rawURL)
return spiderStartedMsg{ch: ch, cancel: cancel, sessionDetected: sessionDetected}
}
}
func (m spiderModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
status := "готов"
if m.running {
status = m.spinner.View() + " краулинг… (esc — остановить)"
if m.sessionDetected {
status += " · сессия подхвачена из Proxy History"
}
}
var b strings.Builder
b.WriteString(focusMark(0) + "Стартовый URL\n")
b.WriteString(m.startURL.View())
b.WriteString("\n")
b.WriteString(focusMark(1) + "Макс. глубина\n")
b.WriteString(m.maxDepth.View())
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("Статус: %s · страниц: %d\n", status, m.count))
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n")
}
b.WriteString("\n" + m.viewport.View())
b.WriteString("\n\ntab — между полями · ctrl+r — запустить · ctrl+c — выход\n")
b.WriteString("f1-f12, pgup — вкладки\n")
return b.String()
}

110
internal/tui/style.go Normal file
View file

@ -0,0 +1,110 @@
package tui
import (
"net/http"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/r3g1tpr0cs/burterm/internal/project"
"github.com/r3g1tpr0cs/burterm/internal/scanner"
)
// Цвета подобраны по классам HTTP-статуса — тот же принцип, что и
// цветовая маркировка в Burp: 2xx зелёный, 3xx голубой, 4xx жёлтый,
// 5xx красный. Сетевые ошибки (таймаут, обрыв соединения — не HTTP-
// статус вовсе) получают отдельный цвет, чтобы не путать с 5xx.
var (
style2xx = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
style3xx = lipgloss.NewStyle().Foreground(lipgloss.Color("51"))
style4xx = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
style5xx = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
styleErrStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("201"))
styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
// styleHighlight — единый стиль для строк, подпавших под пользовательское
// regex-правило подсветки. Намеренно НЕ комбинируется с statusStyle на
// одной строке: lipgloss.Style.Render оборачивает текст кодами
// ESC[...]...ESC[0m, а ESC[0m сбрасывает вообще все атрибуты, включая
// внешние — если вложить один Render внутрь другого, первый же
// внутренний сброс "съест" стиль снаружи для остатка строки. Поэтому
// для подсвеченных строк весь рендер идёт через один-единственный
// styleHighlight, без отдельной раскраски статуса внутри.
styleHighlight = lipgloss.NewStyle().Bold(true).Background(lipgloss.Color("58")).Foreground(lipgloss.Color("230"))
)
// statusStyle подбирает стиль по классу статус-кода.
func statusStyle(code int) lipgloss.Style {
switch {
case code >= 200 && code < 300:
return style2xx
case code >= 300 && code < 400:
return style3xx
case code >= 400 && code < 500:
return style4xx
case code >= 500 && code < 600:
return style5xx
default:
return styleDim
}
}
// colorLabelStyle раскрашивает пользовательскую цветовую метку проекта
// (project.ColorLabel) в её собственный цвет — не путать с statusStyle,
// которая красит по автоматическому классу HTTP-статуса: это разные
// вещи, метка выставляется руками (клавиша "c" на вкладке Project).
func colorLabelStyle(c project.ColorLabel) lipgloss.Style {
switch c {
case project.ColorRed:
return lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
case project.ColorOrange:
return lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
case project.ColorYellow:
return lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
case project.ColorGreen:
return lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
case project.ColorCyan:
return lipgloss.NewStyle().Foreground(lipgloss.Color("51"))
case project.ColorBlue:
return lipgloss.NewStyle().Foreground(lipgloss.Color("33"))
case project.ColorPurple:
return lipgloss.NewStyle().Foreground(lipgloss.Color("129"))
default:
return styleDim
}
}
// severityStyle подбирает стиль для находок Scanner — переиспользует
// ту же цветовую шкалу, что и statusStyle, просто с другим входом.
func severityStyle(sev scanner.Severity) lipgloss.Style {
switch sev {
case scanner.SeverityHigh:
return style5xx
case scanner.SeverityMedium:
return style4xx
case scanner.SeverityLow:
return style3xx
default:
return styleDim
}
}
// contentTypeLabel вытаскивает короткую метку типа контента из заголовка
// Content-Type: "application/json; charset=utf-8" → "json".
func contentTypeLabel(h http.Header) string {
if h == nil {
return ""
}
ct := h.Get("Content-Type")
if ct == "" {
return ""
}
if idx := strings.Index(ct, ";"); idx != -1 {
ct = ct[:idx]
}
ct = strings.TrimSpace(ct)
if idx := strings.LastIndex(ct, "/"); idx != -1 {
return ct[idx+1:]
}
return ct
}

341
internal/tui/targetview.go Normal file
View file

@ -0,0 +1,341 @@
package tui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/r3g1tpr0cs/burterm/internal/proxy"
"github.com/r3g1tpr0cs/burterm/internal/sitemap"
)
// targetModel — вкладка Target/Site Map: поле ввода regex-правила scope,
// список уже добавленных правил (include/exclude) с возможностью удалить
// любое, и построенное из перехваченного трафика дерево путей. Дерево
// пересобирается сразу при добавлении/удалении правила и при каждой новой
// записи от Proxy — отдельной кнопки "обновить" не нужно.
type targetModel struct {
input textinput.Model
scope *sitemap.Scope
entries []proxy.Entry
tree *sitemap.Tree
treeVP viewport.Model
cursor int // индекс выбранного правила в scope.Rules()
focus int // 0 — поле ввода, 1 — список правил, 2 — дерево (скролл)
statusMsg string
errMsg string
}
func newTargetView() targetModel {
in := textinput.New()
in.Placeholder = `regex, напр. ^https://example\.com/api/.*`
in.CharLimit = 512
in.Focus()
return targetModel{
input: in,
scope: sitemap.NewScope(),
treeVP: viewport.New(0, 0),
}
}
func (m *targetModel) SetSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
m.treeVP.Width = width
// 13 фиксированных строк вокруг treeVP (построчный подсчёт по View(),
// baseline без правил/ошибок: "Правило scope"-метка+поле+разделитель
// (3), "Правила (0)"-метка+"(пусто...)"-строка+разделитель (3),
// разделитель+"Карта сайта"-метка (2), разделитель+5 строк футера
// (подсказки клавиш разбиты на две строки, отдельно навигация,
// отдельно примечание про scope) (5) — итого 13. Раньше стояло -10 —
// недосчитано на 2 строки, плюс сам футер был одной длинной строкой
// (свыше 190 символов), которая непредсказуемо переносилась терминалом.
vh := height - 13
if vh < 3 {
vh = 3
}
m.treeVP.Height = vh
}
// AddEntry добавляет перехваченный запрос и сразу пересобирает дерево.
func (m *targetModel) AddEntry(e proxy.Entry) {
m.entries = append(m.entries, e)
m.rebuildTree()
}
func (m *targetModel) rebuildTree() {
records := make([]sitemap.Record, 0, len(m.entries))
for _, e := range m.entries {
errStr := ""
if e.Err != nil {
errStr = e.Err.Error()
}
records = append(records, sitemap.Record{
ID: e.ID,
Method: e.Method,
URL: e.URL,
StatusCode: e.StatusCode,
Err: errStr,
})
}
m.tree = sitemap.Build(records, m.scope)
m.refreshTreeView()
}
func (m *targetModel) refreshTreeView() {
if m.tree == nil || len(m.tree.Hosts) == 0 {
m.treeVP.SetContent("Карта пуста. Перехвати трафик через Proxy или дождись совпадений со scope.")
return
}
var b strings.Builder
hosts := make([]string, 0, len(m.tree.Hosts))
for h := range m.tree.Hosts {
hosts = append(hosts, h)
}
sort.Strings(hosts)
for _, h := range hosts {
b.WriteString(h + "/\n")
renderSitemapNode(&b, m.tree.Hosts[h], 1)
}
m.treeVP.SetContent(b.String())
}
// renderSitemapNode рекурсивно печатает дерево с отступами по глубине:
// сначала дочерние сегменты пути в алфавитном порядке, затем эндпоинты
// на самом узле.
func renderSitemapNode(b *strings.Builder, n *sitemap.Node, depth int) {
indent := strings.Repeat(" ", depth)
names := make([]string, 0, len(n.Children))
for name := range n.Children {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
b.WriteString(indent + name + "/\n")
renderSitemapNode(b, n.Children[name], depth+1)
}
for _, ep := range n.Endpoints {
status := fmt.Sprintf("%d", ep.StatusCode)
if ep.Err != "" {
status = "ERR"
}
b.WriteString(fmt.Sprintf("%s [%s %s] %s\n", indent, ep.Method, status, ep.URL))
}
}
func (m targetModel) Update(msg tea.Msg) (targetModel, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "tab":
m.focus = (m.focus + 1) % 3
if m.focus == 0 {
m.input.Focus()
} else {
m.input.Blur()
}
return m, nil
case "enter":
if m.focus == 0 {
m.addRule(false)
return m, m.scopeChangedCmd()
}
case "ctrl+e":
if m.focus == 0 {
m.addRule(true)
return m, m.scopeChangedCmd()
}
case "ctrl+s":
m.export()
return m, nil
case "ctrl+p":
return m, m.saveScopeCmd()
case "up":
if m.focus == 1 && m.cursor > 0 {
m.cursor--
return m, nil
}
case "down":
if m.focus == 1 && m.cursor < len(m.scope.Rules())-1 {
m.cursor++
return m, nil
}
case "d":
if m.focus == 1 {
m.scope.RemoveAt(m.cursor)
if m.cursor > 0 {
m.cursor--
}
m.rebuildTree()
return m, m.scopeChangedCmd()
}
}
}
// Ключи, не перехваченные выше для текущего фокуса (например, "d" при
// вводе паттерна, или "up"/"down" вне списка правил), уходят как
// обычный ввод в активный компонент — это и есть провал сквозь свитч,
// а не двойная обработка.
var cmd tea.Cmd
switch m.focus {
case 0:
m.input, cmd = m.input.Update(msg)
case 2:
m.treeVP, cmd = m.treeVP.Update(msg)
}
return m, cmd
}
// addRule берёт текст из поля ввода, компилирует как regex и добавляет
// в scope. Регулярка компилируется синхронно (это дёшево), без ухода
// в tea.Cmd — в отличие от сетевых операций в других вкладках, здесь
// блокировать TUI попросту нечем.
func (m *targetModel) addRule(exclude bool) {
pattern := strings.TrimSpace(m.input.Value())
if pattern == "" {
return
}
if err := m.scope.Add(pattern, exclude); err != nil {
m.errMsg = err.Error()
return
}
m.errMsg = ""
m.statusMsg = ""
m.input.SetValue("")
m.rebuildTree()
}
// scopeChangedCmd уведомляет корневую модель, что правила scope
// изменились — она пересоберёт независимый снапшот и передаст его
// в живой proxy.Server, чтобы "скип трафика вне scope" в Proxy сразу
// подхватывал актуальные правила, а не только то, что было на момент
// запуска TUI. Снимок правил делаем сразу (Rules() — дешёвая копия),
// а не откладываем на потом: к моменту, когда tea.Cmd выполнится,
// m.scope мог уже почитаться дальше пользователем.
func (m targetModel) scopeChangedCmd() tea.Cmd {
rules := m.scope.Rules()
return func() tea.Msg {
return scopeChangedMsg{rules: rules}
}
}
// saveScopeCmd (ctrl+p) сериализует текущие scope-правила в JSON и
// отправляет корневой модели — сама targetModel не держит ссылку на
// project.Store (та живёт только в Model), поэтому запись в БД делает
// корневой обработчик saveScopeMsg, а не эта функция напрямую.
// Сериализация здесь синхронная (это просто json.Marshal небольшого
// среза, не I/O) — в tea.Cmd уходит уже готовая строка.
func (m targetModel) saveScopeCmd() tea.Cmd {
data, err := json.Marshal(m.scope.Rules())
return func() tea.Msg {
if err != nil {
return projectErrMsg{fmt.Errorf("сериализация scope-правил: %w", err)}
}
return saveScopeMsg{json: string(data)}
}
}
// export пишет текущее дерево в ~/.burterm/sitemap-<unix-время>.json.
func (m *targetModel) export() {
if m.tree == nil || len(m.tree.Hosts) == 0 {
m.errMsg = "нечего экспортировать — карта пуста"
return
}
path, err := defaultSitemapExportPath()
if err != nil {
m.errMsg = err.Error()
return
}
if err := m.tree.ExportJSON(path); err != nil {
m.errMsg = err.Error()
return
}
m.errMsg = ""
m.statusMsg = "экспортировано в " + path
}
func defaultSitemapExportPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
}
dir := filepath.Join(home, ".burterm")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("не удалось создать %s: %w", dir, err)
}
return filepath.Join(dir, fmt.Sprintf("sitemap-%d.json", time.Now().Unix())), nil
}
func (m targetModel) View() string {
focusMark := func(i int) string {
if m.focus == i {
return "▸ "
}
return " "
}
var b strings.Builder
b.WriteString(focusMark(0) + "Правило scope (regex)\n")
b.WriteString(m.input.View())
b.WriteString("\n\n")
rules := m.scope.Rules()
b.WriteString(focusMark(1) + fmt.Sprintf("Правила (%d)\n", len(rules)))
if len(rules) == 0 {
b.WriteString(" (пусто — весь перехваченный трафик в scope)\n")
}
for i, r := range rules {
marker := " "
if m.focus == 1 && i == m.cursor {
marker = "▸ "
}
kind := "+"
if r.Exclude {
kind = "-"
}
b.WriteString(fmt.Sprintf("%s[%s] %s\n", marker, kind, r.Pattern))
}
b.WriteString("\n")
if m.errMsg != "" {
b.WriteString("Ошибка: " + m.errMsg + "\n")
}
if m.statusMsg != "" {
b.WriteString(m.statusMsg + "\n")
}
b.WriteString("\n" + focusMark(2) + "Карта сайта\n")
b.WriteString(m.treeVP.View())
b.WriteString("\n\ntab — фокус · enter — добавить include · ctrl+e — добавить exclude · d — удалить правило\n")
b.WriteString("ctrl+s — экспорт JSON · ctrl+p — сохранить scope в проект\n")
b.WriteString("f1-f12, pgup — вкладки\n")
b.WriteString("Правила сразу применяются к Proxy: трафик вне scope больше не попадает в историю.\n")
return b.String()
}

162
internal/tui/theme.go Normal file
View file

@ -0,0 +1,162 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Палитра для структурного UI (рамки, заголовки панелей, таб-бар) —
// намеренно отдельная от statusStyle/severityStyle в style.go (те красят
// ДАННЫЕ — статус-коды, находки; эти красят КАРКАС интерфейса). Числа —
// код цвета из 256-цветной ANSI-палитры, не hex/truecolor: она надёжнее
// деградирует на терминалах со скромной поддержкой цвета, а truecolor
// имело бы смысл включать только при уверенности, что терминал его
// действительно поддерживает — здесь такой уверенности нет.
var (
colorBorder = lipgloss.Color("62") // приглушённый синий/фиолетовый — рамки панелей
colorPanelTitle = lipgloss.Color("213") // розовый — заголовки панелей
colorAccent = lipgloss.Color("86") // циан — активная вкладка, акценты
colorMuted = lipgloss.Color("245") // серый — неактивные вкладки, второстепенный текст
colorLogo = lipgloss.Color("86")
)
var (
panelBorderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
Padding(0, 1)
panelTitleStyle = lipgloss.NewStyle().
Foreground(colorPanelTitle).
Bold(true)
tabActiveStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("0")).
Background(colorAccent).
Padding(0, 1)
tabInactiveStyle = lipgloss.NewStyle().
Foreground(colorMuted).
Padding(0, 1)
logoStyle = lipgloss.NewStyle().
Foreground(colorLogo).
Bold(true)
)
// panel оборачивает body в рамку с заголовком первой строкой внутри неё.
// Это не буквально врезанный в саму границу заголовок (как "┌─ TITLE ─┐"
// на референсном скриншоте) — честно говоря, аккуратно вписать текст
// прямо в линию рамки с учётом произвольной ширины панели без реального
// терминала для проверки выравнивания я делать не рискнул: разъедется на
// один символ — и всё выглядит сломанным. Заголовок жирным текстом
// первой строкой внутри рамки — тот же смысловой эффект ("вот подпись
// этой панели"), но без риска съехавшей границы.
func panel(title, body string, width int) string {
if width < 10 {
width = 10
}
content := panelTitleStyle.Render(strings.ToUpper(title)) + "\n" + body
return panelBorderStyle.Width(width).Render(content)
}
// tabDef — одна вкладка таб-бара: горячая клавиша, подпись, идентификатор.
type tabDef struct {
key string
label string
view view
}
var allTabs = []tabDef{
{"f1", "Dash", viewDashboard},
{"f2", "Req", viewRequestBuilder},
{"f3", "Res", viewResults},
{"f4", "Proxy", viewProxy},
{"f5", "Rep", viewRepeater},
{"f6", "Spider", viewSpider},
{"f7", "Target", viewTarget},
{"f8", "Decoder", viewDecoder},
{"f9", "Comparer", viewComparer},
{"f10", "Sequencer", viewSequencer},
{"f11", "Scanner", viewScanner},
{"f12", "Project", viewProject},
{"PgUp", "Fuzzer", viewFuzzer},
}
// renderTabs строит таб-бар в два ряда по семь вкладок — один длинный
// ряд из 14 вкладок надёжно убегает за границу экрана почти на любом
// терминале (сам от этого пострадал на скриншоте: последняя видимая
// вкладка обрезалась серединой слова). Два ряда фиксированной длины —
// не идеальное решение (на очень широком терминале часть строки
// простаивает), но предсказуемое и не требует динамического измерения
// реальной ширины текста с учётом ANSI-кодов, с которым я бы не смог
// свериться без реального терминала под рукой.
//
// Активная вкладка — заливка акцентным цветом, остальные — приглушённый
// текст. reqModified/repModified добавляют "*" к подписи Req/Rep — та
// же индикация несохранённых правок, что была и в чисто текстовой
// версии.
func renderTabs(active view, reqModified, repModified bool) string {
render := func(t tabDef) string {
label := t.label
switch t.view {
case viewRequestBuilder:
if reqModified {
label += "*"
}
case viewRepeater:
if repModified {
label += "*"
}
}
text := fmt.Sprintf("%s:%s", t.key, label)
if t.view == active {
return tabActiveStyle.Render(text)
}
return tabInactiveStyle.Render(text)
}
mid := (len(allTabs) + 1) / 2
row1 := make([]string, 0, mid)
for _, t := range allTabs[:mid] {
row1 = append(row1, render(t))
}
row2 := make([]string, 0, len(allTabs)-mid)
for _, t := range allTabs[mid:] {
row2 = append(row2, render(t))
}
return lipgloss.JoinVertical(lipgloss.Left,
lipgloss.JoinHorizontal(lipgloss.Top, row1...),
lipgloss.JoinHorizontal(lipgloss.Top, row2...),
)
}
// tabTitle возвращает короткую подпись вкладки по её view — используется
// для заголовка внешней рамки-панели, в которую оборачивается тело
// каждой вкладки (см. Model.View в model.go).
func tabTitle(v view) string {
for _, t := range allTabs {
if t.view == v {
return t.label
}
}
return ""
}
// renderLogo — простой стилизованный баннер вместо блочного ASCII-арта.
// Причина не пытаться повторить "PENTOOL" с референса буква-в-букву:
// блочные ASCII-буквы требуют точной посимвольной геометрии (высота
// каждой буквы, зазоры), и без реального терминала под рукой я не могу
// проверить, не съехало ли что-то на один пробел — а кривой ASCII-арт
// выглядит хуже, чем его отсутствие. Разрядка через пробел + акцентный
// цвет + горизонтальная линия — тот же "заставочный" эффект с кратно
// меньшим риском.
func renderLogo() string {
spaced := strings.Join(strings.Split("BURTERM", ""), " ")
line := strings.Repeat("─", len(spaced))
return logoStyle.Render(spaced) + "\n" + panelTitleStyle.Render(line)
}