diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..87ad8e3 --- /dev/null +++ b/ARCHITECTURE.md @@ -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`, `` / +`range:::` / `preset:` / `brute:::`, +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//` +(fuzzer crash inputs), `sitemap-.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. diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md new file mode 100644 index 0000000..fbe74fb --- /dev/null +++ b/ARCHITECTURE.ru.md @@ -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`, `` / +`range:::` / `preset:<имя>` / `brute:<алфавит>::`, +с необязательным суффиксом `|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//` (крашащие вводы +фаззера), `sitemap-.json` (экспорт карты сайта). + +## Режимы запуска + +TUI (без аргументов) и headless CLI (`-request`/`-payload`/`-mode` и +т.д., см. `internal/cli/flags.go`) используют один и тот же +`internal/engine` — сценарии CI/скриптов не дублируют логику атаки. diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..61cccd9 --- /dev/null +++ b/NOTES.md @@ -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 последними — там + наибольшая оставшаяся неопределённость. diff --git a/README.md b/README.md new file mode 100644 index 0000000..027d2b8 --- /dev/null +++ b/README.md @@ -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) +``` diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..9aed8d3 --- /dev/null +++ b/README.ru.md @@ -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) +``` diff --git a/cmd/burterm/main.go b/cmd/burterm/main.go new file mode 100644 index 0000000..eeb793f --- /dev/null +++ b/cmd/burterm/main.go @@ -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) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9f6edbe --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/internal/cli/flags.go b/internal/cli/flags.go new file mode 100644 index 0000000..ece6885 --- /dev/null +++ b/internal/cli/flags.go @@ -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:::", по одной на точку вставки + 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::: | preset:<имя> | brute:<алфавит>:: — к любому можно дописать |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:::" одинаково. +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:<алфавит>:: — прямой перебор без + // готовой заготовки, для случаев, когда ни одна из встроенных + // не подходит (свой алфавит, другая длина и т.п.). + parts := strings.SplitN(arg, ":", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("ожидался формат brute:<алфавит>::") + } + 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 +} diff --git a/internal/comparer/diff.go b/internal/comparer/diff.go new file mode 100644 index 0000000..4e6b622 --- /dev/null +++ b/internal/comparer/diff.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..bb97f9a --- /dev/null +++ b/internal/config/config.go @@ -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, + } +} diff --git a/internal/decoder/decoder.go b/internal/decoder/decoder.go new file mode 100644 index 0000000..589d271 --- /dev/null +++ b/internal/decoder/decoder.go @@ -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-сущности (<, &, ' и т.п.). +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 +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..71478c2 --- /dev/null +++ b/internal/engine/engine.go @@ -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 // все разряды переполнились — перебор завершён + } + } +} diff --git a/internal/engine/filter.go b/internal/engine/filter.go new file mode 100644 index 0000000..73b5c06 --- /dev/null +++ b/internal/engine/filter.go @@ -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-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 + } +} diff --git a/internal/engine/request.go b/internal/engine/request.go new file mode 100644 index 0000000..c13829f --- /dev/null +++ b/internal/engine/request.go @@ -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 +} diff --git a/internal/fuzzer/fuzzer.go b/internal/fuzzer/fuzzer.go new file mode 100644 index 0000000..7c0d989 --- /dev/null +++ b/internal/fuzzer/fuzzer.go @@ -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 +} diff --git a/internal/fuzzer/mutate.go b/internal/fuzzer/mutate.go new file mode 100644 index 0000000..abc0f92 --- /dev/null +++ b/internal/fuzzer/mutate.go @@ -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 +} diff --git a/internal/payload/generator.go b/internal/payload/generator.go new file mode 100644 index 0000000..bdfd70f --- /dev/null +++ b/internal/payload/generator.go @@ -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:<алфавит>::". +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 +} diff --git a/internal/payload/presets.go b/internal/payload/presets.go new file mode 100644 index 0000000..38bb69f --- /dev/null +++ b/internal/payload/presets.go @@ -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{ + ``, `">`, + `'>`, ``, + `javascript:alert(1)`, `">`, +} + +var pathTraversalProbes = []string{ + `../`, `../../`, `../../../etc/passwd`, + `..\..\..\windows\win.ini`, `%2e%2e%2f`, `....//....//etc/passwd`, +} diff --git a/internal/payload/smart.go b/internal/payload/smart.go new file mode 100644 index 0000000..3827796 --- /dev/null +++ b/internal/payload/smart.go @@ -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 +} diff --git a/internal/project/lastpath.go b/internal/project/lastpath.go new file mode 100644 index 0000000..4604183 --- /dev/null +++ b/internal/project/lastpath.go @@ -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 +} diff --git a/internal/project/query.go b/internal/project/query.go new file mode 100644 index 0000000..4f7122d --- /dev/null +++ b/internal/project/query.go @@ -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 +} diff --git a/internal/project/store.go b/internal/project/store.go new file mode 100644 index 0000000..9de0cfb --- /dev/null +++ b/internal/project/store.go @@ -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 +} diff --git a/internal/proxy/ca.go b/internal/proxy/ca.go new file mode 100644 index 0000000..d37099a --- /dev/null +++ b/internal/proxy/ca.go @@ -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") +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..c00425a --- /dev/null +++ b/internal/proxy/proxy.go @@ -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), + }) +} diff --git a/internal/proxy/session.go b/internal/proxy/session.go new file mode 100644 index 0000000..27e08c3 --- /dev/null +++ b/internal/proxy/session.go @@ -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 +} diff --git a/internal/proxy/store.go b/internal/proxy/store.go new file mode 100644 index 0000000..e50890d --- /dev/null +++ b/internal/proxy/store.go @@ -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 +} diff --git a/internal/scanner/canary.go b/internal/scanner/canary.go new file mode 100644 index 0000000..2629986 --- /dev/null +++ b/internal/scanner/canary.go @@ -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) +} diff --git a/internal/scanner/checks.go b/internal/scanner/checks.go new file mode 100644 index 0000000..220a631 --- /dev/null +++ b/internal/scanner/checks.go @@ -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)`, tag, tag), + fmt.Sprintf(`"><%s>alert(1)`, tag, tag), + fmt.Sprintf(`'>`, tag), + fmt.Sprintf(`javascript:%s(1)`, tag), + } +} + +func (c *xssCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding { + if resp.Err != nil { + return nil + } + // Ищем канарейку в СЫРОМ виде (с угловыми скобками/как есть) — если + // сервер её экранировал (<wrXXXX>), это не отражение, а + // корректное поведение, и мы не должны его засчитывать как находку. + 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{ + `]>&xxe;`, + `]>&xxe;`, + } +} + +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 +} diff --git a/internal/scanner/cors.go b/internal/scanner/cors.go new file mode 100644 index 0000000..1182a5d --- /dev/null +++ b/internal/scanner/cors.go @@ -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 +} diff --git a/internal/scanner/finding.go b/internal/scanner/finding.go new file mode 100644 index 0000000..b5cc194 --- /dev/null +++ b/internal/scanner/finding.go @@ -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) +} diff --git a/internal/scanner/jwt.go b/internal/scanner/jwt.go new file mode 100644 index 0000000..5d12f60 --- /dev/null +++ b/internal/scanner/jwt.go @@ -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 +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..d712658 --- /dev/null +++ b/internal/scanner/scanner.go @@ -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 +} diff --git a/internal/sequencer/fips.go b/internal/sequencer/fips.go new file mode 100644 index 0000000..61b79f1 --- /dev/null +++ b/internal/sequencer/fips.go @@ -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 +} diff --git a/internal/sitemap/scope.go b/internal/sitemap/scope.go new file mode 100644 index 0000000..7a28bc3 --- /dev/null +++ b/internal/sitemap/scope.go @@ -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 +} diff --git a/internal/sitemap/tree.go b/internal/sitemap/tree.go new file mode 100644 index 0000000..d3e1d99 --- /dev/null +++ b/internal/sitemap/tree.go @@ -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 +} diff --git a/internal/spider/spider.go b/internal/spider/spider.go new file mode 100644 index 0000000..c4d1192 --- /dev/null +++ b/internal/spider/spider.go @@ -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, и содержимое за ними иначе никогда не попало бы в обход + // (оно не существует как , пока форму не отправить). 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 и вытаскивает абсолютные ссылки +// из , а также описания форм из
/. +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) +} diff --git a/internal/tui/comparerview.go b/internal/tui/comparerview.go new file mode 100644 index 0000000..d81b528 --- /dev/null +++ b/internal/tui/comparerview.go @@ -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() +} diff --git a/internal/tui/dashboardview.go b/internal/tui/dashboardview.go new file mode 100644 index 0000000..17bdc47 --- /dev/null +++ b/internal/tui/dashboardview.go @@ -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" +} diff --git a/internal/tui/decoderview.go b/internal/tui/decoderview.go new file mode 100644 index 0000000..7379286 --- /dev/null +++ b/internal/tui/decoderview.go @@ -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() +} diff --git a/internal/tui/fuzzerview.go b/internal/tui/fuzzerview.go new file mode 100644 index 0000000..efec559 --- /dev/null +++ b/internal/tui/fuzzerview.go @@ -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// — не нужно отдельное поле +// ввода ради этого. +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 "файл (@@)" +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..e370368 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,1413 @@ +// Package tui реализует интерактивный интерфейс burterm на bubbletea. +// +// Собрано под bubbles v0.20.0 / bubbletea v1.2.4 (см. go.mod). Собрать и +// проверить компиляцию в этом окружении не удалось (нет сети до +// proxy.golang.org и не установлен сам Go) — при первой сборке возможны +// мелкие несовпадения сигнатур (напр. если Focus() в твоей версии bubbles +// не возвращает tea.Cmd) — правится точечно по ошибкам компилятора. +package tui + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "runtime" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/r3g1tpr0cs/burterm/internal/cli" + "github.com/r3g1tpr0cs/burterm/internal/engine" + "github.com/r3g1tpr0cs/burterm/internal/fuzzer" + "github.com/r3g1tpr0cs/burterm/internal/payload" + "github.com/r3g1tpr0cs/burterm/internal/project" + "github.com/r3g1tpr0cs/burterm/internal/proxy" + "github.com/r3g1tpr0cs/burterm/internal/scanner" + "github.com/r3g1tpr0cs/burterm/internal/sitemap" + "github.com/r3g1tpr0cs/burterm/internal/spider" +) + +type view int + +const ( + viewRequestBuilder view = iota + viewResults + viewProxy + viewRepeater + viewSpider + viewTarget + viewDecoder + viewComparer + viewSequencer + viewDashboard + viewScanner + viewProject + viewFuzzer +) + +// resultMsg — одна строка результата, помеченная ID атаки, которой она +// принадлежит. Это критично, когда одновременно идёт несколько атак: +// без ID результаты разных прогонов было бы невозможно развести +// по своим resultsModel. +type resultMsg struct { + attackID int + result engine.Result +} + +// resultsDoneMsg — канал результатов конкретной атаки закрылся. +type resultsDoneMsg struct{ attackID int } + +// attackStartedMsg несёт канал результатов, функцию отмены и ожидаемое +// общее число запросов (для индикации прогресса N/Total). +type attackStartedMsg struct { + ch <-chan engine.Result + cancel context.CancelFunc + total int + recursion *recursionMeta // nil — рекурсия выключена для этой атаки +} + +// errMsg — ошибка валидации формы или запуска атаки на вкладке Request. +type errMsg struct{ err error } + +// proxyEntryMsg — новая запись, перехваченная прокси-сервером. +type proxyEntryMsg proxy.Entry + +// loadTarget указывает, куда именно отправить перехваченный запрос +// из вкладки Proxy: в форму Intruder-атаки или в Repeater. +type loadTarget int + +const ( + targetBuilder loadTarget = iota + targetRepeater +) + +// loadEntryMsg — команда "загрузить этот перехваченный запрос", возникает +// на вкладке Proxy по enter (targetBuilder) или s (targetRepeater). +type loadEntryMsg struct { + entry proxy.Entry + target loadTarget +} + +// repeaterRespMsg — ответ, полученный Repeater'ом на ручную отправку запроса. +type repeaterRespMsg struct { + response string + status string + statusCode int + duration time.Duration +} + +// repeaterErrMsg — отдельный тип ошибки для Repeater, чтобы не переключать +// вкладку на Request при ошибке отправки из Repeater. +type repeaterErrMsg struct{ err error } + +// spiderStartedMsg несёт канал найденных страниц и функцию отмены обхода. +type spiderStartedMsg struct { + ch <-chan spider.Page + cancel context.CancelFunc + sessionDetected bool // сессионные Cookie/Authorization подхвачены из истории Proxy +} + +// spiderPageMsg — одна найденная страница, полученная из канала spider.Page. +type spiderPageMsg spider.Page + +// spiderDoneMsg сигнализирует, что канал страниц закрыт — обход завершён. +type spiderDoneMsg struct{} + +// spiderErrMsg — ошибка валидации формы или запуска краулинга. +type spiderErrMsg struct{ err error } + +// dashboardTickMsg — сигнал раз в секунду для обновления метрик на +// вкладке Dashboard. В отличие от остальных вкладок, здесь нет события, +// по приходу которого можно было бы обновиться, — метрики процесса +// (горутины, память) нужно перечитывать даже тогда, когда никаких новых +// сообщений не приходит. Второе (и пока последнее) место в приложении, +// где используется tea.Tick — см. resultsTickMsg ниже. +type dashboardTickMsg time.Time + +// resultsTickMsg — сигнал раз в 300мс на рендер видимой сейчас атаки +// в Results (см. doc у resultsTickCmd и у resultsModel). +type resultsTickMsg time.Time + +// scanStartedMsg несёт канал событий сканирования и функцию отмены. +type scanStartedMsg struct { + ch <-chan scanner.Event + cancel context.CancelFunc +} + +// scanFindingMsg — одна находка, извлечённая из scanner.Event{Kind: EventFinding}. +type scanFindingMsg scanner.Finding + +// scanProgressMsg — тик прогресса, извлечённый из scanner.Event{Kind: EventProgress}. +type scanProgressMsg struct{ done, total int } + +// scanDoneMsg сигнализирует, что канал событий закрылся — сканирование завершено. +type scanDoneMsg struct{} + +// scanErrMsg — ошибка валидации формы или запуска сканирования. +type scanErrMsg struct{ err error } + +// corsResultMsg — результат одноразовой CORS-проверки (ctrl+o на вкладке +// Scanner). finding может быть nil — это означает "проверка прошла, но +// небезопасной конфигурации не найдено", а не ошибку. +type corsResultMsg struct{ finding *scanner.Finding } + +// projectOpenedMsg — файл проекта успешно открыт/создан. +type projectOpenedMsg struct { + store *project.Store + path string +} + +// projectErrMsg — ошибка любой операции с проектом (открытие, поиск, +// смена метки, удаление, автосохранение из Proxy). +type projectErrMsg struct{ err error } + +// projectSearchMsg — результат поиска по проекту. +type projectSearchMsg struct{ results []project.Record } + +// projectColorSetMsg — цветовая метка записи обновлена в БД, нужно +// отразить это в уже загруженном списке результатов без перезапроса. +type projectColorSetMsg struct { + id int64 + color project.ColorLabel +} + +// projectDeletedMsg — запись удалена из БД и должна пропасть из списка. +type projectDeletedMsg struct{ id int64 } + +// projectLoadMsg — команда "загрузить эту сохранённую запись" с вкладки +// Project, аналог loadEntryMsg для Proxy. +type projectLoadMsg struct { + record project.Record + target loadTarget +} + +// saveToProjectMsg — команда "сохрани эти записи в открытый проект", +// возникает по ctrl+p на Repeater/Results/Scanner. origin — откуда +// пришла команда, чтобы результат (успех/ошибка) можно было отразить +// именно в той вкладке, где нажали ctrl+p, а не только на вкладке Project. +type saveToProjectMsg struct { + origin view + records []project.Record +} + +// projectSaveResultMsg — итог сохранения по saveToProjectMsg. +type projectSaveResultMsg struct { + origin view + saved, total int + err error +} + +// saveScopeMsg — команда "сохрани текущие scope-правила Target в проект" +// (ctrl+p на вкладке Target). json уже сериализован в targetModel — +// корневая модель только пишет готовую строку в meta. +type saveScopeMsg struct{ json string } + +// projectMetaSavedMsg — итог сохранения scope-правил. +type projectMetaSavedMsg struct{ err error } + +// scopeChangedMsg — правила scope на Target изменились (добавили/удалили +// правило) и нужно передать актуальный снимок в живой proxy.Server, чтобы +// скип трафика вне scope работал по свежим правилам, а не по тем, что +// были на момент запуска TUI. +type scopeChangedMsg struct{ rules []sitemap.Rule } + +// fuzzStartedMsg несёт канал событий фаззера, функцию отмены и путь +// к директории, куда сохраняются краши. +type fuzzStartedMsg struct { + ch <-chan fuzzer.Event + cancel context.CancelFunc + crashDir string +} + +// fuzzCrashMsg — одна находка, извлечённая из fuzzer.Event{Kind: EventCrash}. +type fuzzCrashMsg fuzzer.Crash + +// fuzzProgressMsg — тик прогресса, извлечённый из fuzzer.Event{Kind: EventProgress}. +type fuzzProgressMsg struct{ execs int64 } + +// fuzzDoneMsg сигнализирует, что канал событий закрылся — фаззинг остановлен. +type fuzzDoneMsg struct{} + +// fuzzErrMsg — ошибка валидации формы или запуска фаззера. +type fuzzErrMsg struct{ err error } + +// attackRun — одна запущенная Intruder-атака: канал результатов, функция +// отмены именно этой атаки (не всех разом) и её собственная вкладка +// результатов. Несколько attackRun могут существовать и получать +// результаты одновременно — так и обеспечивается параллельность атак +// на уровне TUI, а не только на уровне воркеров внутри одной атаки. +type attackRun struct { + id int + ch <-chan engine.Result + cancel context.CancelFunc + results resultsModel + running bool + startedAt time.Time + total int // ожидаемое общее число запросов, 0 — неизвестно/не показывать % + + recursion *recursionMeta // nil — рекурсия выключена для этой атаки +} + +// recursionMeta — всё, что нужно, чтобы после завершения атаки +// автоматически запустить новые под-атаки внутри найденных "директорий" +// (аналог -recursion у ffuf). Хранит СПЕЦИФИКАЦИИ payload'ов (строки +// вида "wordlist:common.txt"), а не уже готовые payload.Generator — +// генераторы одноразовые (Next/Reset потребляют состояние), для каждой +// под-атаки нужны свежие экземпляры, поэтому пересобираются из тех же +// specs через cli.ParsePayloadSpec заново при каждом рекурсивном вызове. +type recursionMeta struct { + rawTemplate string // исходный сырой текст запроса, всё ещё с §маркером§ + pointIndex int // индекс точки вставки, по которой идёт рекурсия (сейчас всегда 0 — см. requestBuilderModel.runCmd) + mode engine.AttackMode + specs []string + depth int + maxDepth int + statusPred engine.StatusFilter // какие статусы считаются "нашли директорию, стоит углубиться" + + concurrency int + rateLimit time.Duration + timeout time.Duration + insecure bool + forceScheme string +} + +// Model — корневая модель приложения: тринадцать вкладок (форма запроса, +// результаты — с возможностью переключаться между несколькими +// одновременно идущими атаками, перехваченные прокси-запросы, Repeater, +// Spider, Target/Site Map, Decoder, Comparer, Sequencer, Dashboard, +// Scanner, Project, Fuzzer). +type Model struct { + active view + width int + height int + + builder requestBuilderModel + proxyView proxyModel + repeaterView repeaterModel + spiderView spiderModel + targetView targetModel + decoderView decoderModel + comparerView comparerModel + sequencerVw sequencerModel + dashboardVw dashboardModel + scannerVw scannerModel + projectView projectModel + fuzzerVw fuzzerModel + + attacks []*attackRun + activeAttack int + nextAttackID int + + spiderCh <-chan spider.Page + spiderCancel context.CancelFunc + + scanCh <-chan scanner.Event + scanCancel context.CancelFunc + + fuzzCh <-chan fuzzer.Event + fuzzCancel context.CancelFunc + + projectStore *project.Store // nil, пока проект не открыт; закрывается при выходе (ctrl+c) или при открытии другого + + proxyCh <-chan proxy.Entry + proxyAddr string + caPath string + proxyServer *proxy.Server // для прокидывания scope-фильтра (SetScope) при изменении правил на Target + + programStart time.Time +} + +// New создаёт начальное состояние TUI. proxyStore может быть nil (если +// прокси не удалось поднять) — тогда вкладка Proxy просто не будет +// получать новые записи. proxyServer — сам работающий Server (для +// прокидывания scope-фильтра при изменении правил на Target), тоже может +// быть nil в паре с proxyStore. projectStore может быть nil (проект ещё +// не открывался, либо автозагрузка последнего проекта не удалась) — +// тогда вкладка Project стартует пустой, как и раньше, ctrl+o по-прежнему +// открывает/создаёт файл вручную. +func New(proxyStore *proxy.Store, proxyAddr, caPath string, proxyServer *proxy.Server, projectStore *project.Store) Model { + pv := newProxyView() + tv := newTargetView() + + var ch <-chan proxy.Entry + if proxyStore != nil { + for _, e := range proxyStore.All() { + pv.AddEntry(e) + tv.AddEntry(e) + } + ch = proxyStore.Subscribe() + } + + pjv := newProjectView() + if projectStore != nil { + pjv.store = projectStore + pjv.path = projectStore.Path + pjv.pathInput.SetValue(projectStore.Path) + applyProjectScope(&tv, projectStore) + } + pushScopeToProxy(proxyServer, tv.scope) + + m := Model{ + active: viewDashboard, + builder: newRequestBuilder(), + proxyView: pv, + repeaterView: newRepeater(), + spiderView: newSpiderView(), + targetView: tv, + decoderView: newDecoderView(), + comparerView: newComparerView(), + sequencerVw: newSequencerView(), + dashboardVw: newDashboardView(), + scannerVw: newScannerView(), + projectView: pjv, + fuzzerVw: newFuzzerView(), + projectStore: projectStore, + proxyCh: ch, + proxyAddr: proxyAddr, + caPath: caPath, + proxyServer: proxyServer, + programStart: time.Now(), + } + + // Dashboard теперь стартовая вкладка — без этого пользователь первую + // секунду видел бы пустую панель: содержимое обычно заполняется + // тиком dashboardTickCmd раз в секунду, а раньше это было незаметно, + // потому что на Dashboard физически не успевали переключиться так + // быстро. Один синхронный рендер здесь дешёвый (то же самое, что и + // renderDashboard() внутри тика) и не блокирует ничего сетевого. + m.dashboardVw.SetContent(m.renderDashboard()) + + return m +} + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{dashboardTickCmd(), resultsTickCmd()} + if m.proxyCh != nil { + cmds = append(cmds, waitForProxyEntry(m.proxyCh)) + } + return tea.Batch(cmds...) +} + +// dashboardTickCmd планирует следующее обновление метрик через секунду. +// Вызывается один раз в Init и затем сам себя переиздаёт в обработчике +// dashboardTickMsg — так тикер живёт всё время работы TUI, не завися +// от того, какая вкладка сейчас активна. +func dashboardTickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return dashboardTickMsg(t) + }) +} + +// resultsTickCmd планирует следующий рендер результатов через 300мс — +// тот же принцип, что и dashboardTickCmd, но чаще (результаты атаки +// хочется видеть live, не раз в секунду) и с более узкой целью: тик +// коммитит (Commit) только ту атаку, что сейчас видна на вкладке +// Results — остальные копят content в фоне без дорогого +// viewport.SetContent, пока пользователь на них не переключится +// (см. doc resultsModel в results.go). +func resultsTickCmd() tea.Cmd { + return tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg { + return resultsTickMsg(t) + }) +} + +// findAttack ищет запущенную атаку по ID. attacks — срез указателей, +// поэтому найденный *attackRun можно мутировать напрямую даже притом, +// что Model передаётся по значению — сам срез копируется, но элементы +// в нём общие. +func (m Model) findAttack(id int) *attackRun { + for _, a := range m.attacks { + if a.id == id { + return a + } + } + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.builder.textarea.SetWidth(msg.Width - 2) + m.builder.textarea.SetHeight(msg.Height / 3) + + // Бюджеты высоты ниже учитывают только "хром" таб-бара: он стал + // двухрядным вместо однорядного, +1 строка к тому, что было + // исходно (-6/-8). Раньше здесь была ещё поправка на panel(), + // в которую заворачивалось тело каждой вкладки — откатил её + // (см. комментарий в Model.View): дважды подряд не смог вслепую + // подобрать для неё правильный бюджет высоты, то меню обрезалось, + // то сама рамка вылезала за край экрана. Теперь чрома меньше и + // компенсация проще — есть шанс, что +1 наконец точное число, + // но и это не проверено на реальном терминале. + for _, a := range m.attacks { + a.results.SetSize(msg.Width-2, msg.Height-6) + } + m.proxyView.SetSize(msg.Width-2, msg.Height-5) + m.repeaterView.SetSize(msg.Width-2, msg.Height-9) + m.spiderView.SetSize(msg.Width-2, msg.Height-7) + m.targetView.SetSize(msg.Width-2, msg.Height-7) + m.decoderView.SetSize(msg.Width-2, msg.Height-7) + m.comparerView.SetSize(msg.Width-2, msg.Height-3) + m.sequencerVw.SetSize(msg.Width-2, msg.Height-7) + m.dashboardVw.SetSize(msg.Width-2, msg.Height-7) + m.scannerVw.SetSize(msg.Width-2, msg.Height-7) + m.projectView.SetSize(msg.Width-2, msg.Height-7) + m.fuzzerVw.SetSize(msg.Width-2, msg.Height-7) + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + // Выход останавливает все текущие атаки и краулинг, а не только + // видимую вкладку — иначе фоновые остались бы висеть до + // истечения таймаутов. + for _, a := range m.attacks { + if a.cancel != nil { + a.cancel() + } + } + if m.spiderCancel != nil { + m.spiderCancel() + } + if m.scanCancel != nil { + m.scanCancel() + } + if m.fuzzCancel != nil { + m.fuzzCancel() + } + if m.projectStore != nil { + m.projectStore.Close() + } + return m, tea.Quit + case "f1": + m.active = viewDashboard + return m, nil + case "f2": + m.active = viewRequestBuilder + return m, nil + case "f3": + m.active = viewResults + if a := m.currentAttack(); a != nil { + a.results.Commit() + } + return m, nil + case "f4": + m.active = viewProxy + return m, nil + case "f5": + m.active = viewRepeater + return m, nil + case "f6": + m.active = viewSpider + return m, nil + case "f7": + m.active = viewTarget + return m, nil + case "f8": + m.active = viewDecoder + return m, nil + case "f9": + m.active = viewComparer + return m, nil + case "f10": + m.active = viewSequencer + return m, nil + case "f11": + m.active = viewScanner + return m, nil + case "f12": + m.active = viewProject + return m, nil + case "pgup": + m.active = viewFuzzer + return m, nil + case "esc": + // esc отменяет только то, что видно на экране прямо сейчас — + // остальное (другие атаки, краулинг) продолжает идти в фоне. + if m.active == viewResults { + if a := m.currentAttack(); a != nil && a.running && a.cancel != nil { + a.cancel() + } + } + if m.active == viewSpider && m.spiderView.running && m.spiderCancel != nil { + m.spiderCancel() + } + if m.active == viewScanner && m.scannerVw.running && m.scanCancel != nil { + m.scanCancel() + } + if m.active == viewFuzzer && m.fuzzerVw.running && m.fuzzCancel != nil { + m.fuzzCancel() + } + return m, nil + case "[": + if m.active == viewResults && m.activeAttack > 0 { + m.activeAttack-- + if a := m.currentAttack(); a != nil { + a.results.Commit() + } + } + return m, nil + case "]": + if m.active == viewResults && m.activeAttack < len(m.attacks)-1 { + m.activeAttack++ + if a := m.currentAttack(); a != nil { + a.results.Commit() + } + } + return m, nil + } + + case attackStartedMsg: + id := m.nextAttackID + m.nextAttackID++ + + run := &attackRun{id: id, ch: msg.ch, cancel: msg.cancel, results: newResults(), running: true, startedAt: time.Now(), total: msg.total, recursion: msg.recursion} + run.results.Reset() + run.results.SetSize(m.width-2, m.height-6) + + m.attacks = append(m.attacks, run) + m.activeAttack = len(m.attacks) - 1 + m.active = viewResults + return m, waitForResult(id, run.ch) + + case resultMsg: + if a := m.findAttack(msg.attackID); a != nil { + a.results.AddResult(msg.result) + return m, waitForResult(msg.attackID, a.ch) + } + return m, nil + + case resultsDoneMsg: + if a := m.findAttack(msg.attackID); a != nil { + a.running = false + a.results.Finish() + if a.recursion != nil && a.recursion.depth < a.recursion.maxDepth { + if cmd := spawnRecursiveAttacks(*a.recursion, a.results.all); cmd != nil { + return m, cmd + } + } + } + return m, nil + + case errMsg: + m.builder.errMsg = msg.err.Error() + m.active = viewRequestBuilder + return m, nil + + case proxyEntryMsg: + entry := proxy.Entry(msg) + m.proxyView.AddEntry(entry) + m.targetView.AddEntry(entry) + m.spiderView.AddProxyEntry(entry) + if m.projectStore != nil { + return m, tea.Batch(waitForProxyEntry(m.proxyCh), projectAutoSaveCmd(m.projectStore, entry)) + } + return m, waitForProxyEntry(m.proxyCh) + + case loadEntryMsg: + switch msg.target { + case targetRepeater: + m.repeaterView.request.SetValue(msg.entry.Raw) + m.repeaterView.focus = 0 + m.repeaterView.request.Focus() + m.active = viewRepeater + default: + m.builder.textarea.SetValue(msg.entry.Raw) + m.builder.focusIdx = 0 + m.builder.applyFocus() + m.active = viewRequestBuilder + } + return m, nil + + case repeaterRespMsg: + m.repeaterView.sending = false + m.repeaterView.sentCount++ + m.repeaterView.lastStatus = msg.status + m.repeaterView.lastDuration = msg.duration + m.repeaterView.lastResponseRaw = msg.response + m.repeaterView.lastStatusCode = msg.statusCode + m.repeaterView.response.SetContent(msg.response) + m.repeaterView.response.GotoTop() + return m, nil + + case repeaterErrMsg: + m.repeaterView.sending = false + m.repeaterView.errMsg = msg.err.Error() + return m, nil + + case spiderStartedMsg: + m.spiderView.running = true + m.spiderView.pages = nil + m.spiderView.count = 0 + m.spiderView.errMsg = "" + m.spiderView.sessionDetected = msg.sessionDetected + m.spiderCh = msg.ch + m.spiderCancel = msg.cancel + m.active = viewSpider + return m, tea.Batch(waitForSpiderPage(m.spiderCh), m.spiderView.spinner.Tick) + + case spiderPageMsg: + m.spiderView.AddPage(spider.Page(msg)) + return m, waitForSpiderPage(m.spiderCh) + + case spiderDoneMsg: + m.spiderView.Finish() + return m, nil + + case spiderErrMsg: + m.spiderView.running = false + m.spiderView.errMsg = msg.err.Error() + return m, nil + + case scanStartedMsg: + m.scannerVw.Reset() + m.scanCh = msg.ch + m.scanCancel = msg.cancel + return m, waitForScanEvent(m.scanCh) + + case scanFindingMsg: + m.scannerVw.AddFinding(scanner.Finding(msg)) + return m, waitForScanEvent(m.scanCh) + + case scanProgressMsg: + m.scannerVw.progressDone = msg.done + m.scannerVw.progressTotal = msg.total + return m, waitForScanEvent(m.scanCh) + + case scanDoneMsg: + m.scannerVw.Finish() + return m, nil + + case scanErrMsg: + m.scannerVw.running = false + m.scannerVw.errMsg = msg.err.Error() + return m, nil + + case fuzzStartedMsg: + m.fuzzerVw.Reset() + m.fuzzerVw.crashDir = msg.crashDir + m.fuzzCh = msg.ch + m.fuzzCancel = msg.cancel + return m, waitForFuzzEvent(m.fuzzCh) + + case fuzzCrashMsg: + m.fuzzerVw.AddCrash(fuzzer.Crash(msg)) + return m, waitForFuzzEvent(m.fuzzCh) + + case fuzzProgressMsg: + m.fuzzerVw.execs = msg.execs + return m, waitForFuzzEvent(m.fuzzCh) + + case fuzzDoneMsg: + m.fuzzerVw.Finish() + return m, nil + + case fuzzErrMsg: + m.fuzzerVw.running = false + m.fuzzerVw.errMsg = msg.err.Error() + return m, nil + + case corsResultMsg: + if msg.finding != nil { + m.scannerVw.AddFinding(*msg.finding) + } else { + m.scannerVw.statusMsg = "CORS: небезопасной конфигурации не найдено" + } + return m, nil + + case projectOpenedMsg: + if m.projectStore != nil { + m.projectStore.Close() + } + m.projectStore = msg.store + m.projectView.store = msg.store + m.projectView.path = msg.path + m.projectView.errMsg = "" + m.projectView.statusMsg = "проект открыт: " + msg.path + + // Подхватываем scope-правила, если этот проект их уже сохранял + // (ctrl+p на Target), и сразу прокидываем их в живой прокси. + applyProjectScope(&m.targetView, msg.store) + pushScopeToProxy(m.proxyServer, m.targetView.scope) + return m, nil + + case scopeChangedMsg: + snapshot := sitemap.NewScope() + for _, r := range msg.rules { + _ = snapshot.Add(r.Pattern, r.Exclude) + } + if m.proxyServer != nil { + m.proxyServer.SetScope(snapshot) + } + return m, nil + + case projectErrMsg: + m.projectView.errMsg = msg.err.Error() + return m, nil + + case projectSearchMsg: + m.projectView.results = msg.results + m.projectView.cursor = 0 + m.projectView.statusMsg = fmt.Sprintf("найдено: %d", len(msg.results)) + m.projectView.refreshList() + return m, nil + + case projectColorSetMsg: + for i := range m.projectView.results { + if m.projectView.results[i].ID == msg.id { + m.projectView.results[i].ColorLabel = msg.color + break + } + } + m.projectView.refreshList() + return m, nil + + case projectDeletedMsg: + for i, r := range m.projectView.results { + if r.ID == msg.id { + m.projectView.results = append(m.projectView.results[:i], m.projectView.results[i+1:]...) + break + } + } + if m.projectView.cursor >= len(m.projectView.results) { + m.projectView.cursor = len(m.projectView.results) - 1 + } + m.projectView.refreshList() + return m, nil + + case projectLoadMsg: + switch msg.target { + case targetRepeater: + m.repeaterView.request.SetValue(msg.record.RawRequest) + m.repeaterView.focus = 0 + m.repeaterView.request.Focus() + m.active = viewRepeater + default: + m.builder.textarea.SetValue(msg.record.RawRequest) + m.builder.focusIdx = 0 + m.builder.applyFocus() + m.active = viewRequestBuilder + } + return m, nil + + case saveToProjectMsg: + if m.projectStore == nil { + text := "нет открытого проекта — открой на вкладке Project (ctrl+o)" + switch msg.origin { + case viewRepeater: + m.repeaterView.errMsg = text + case viewResults: + if a := m.currentAttack(); a != nil { + a.results.saveMsg = text + } + case viewScanner: + m.scannerVw.errMsg = text + } + return m, nil + } + return m, projectSaveCmd(m.projectStore, msg.origin, msg.records) + + case projectSaveResultMsg: + text := fmt.Sprintf("сохранено %d/%d в проект", msg.saved, msg.total) + if msg.err != nil { + text += fmt.Sprintf(" (первая ошибка: %v)", msg.err) + } + switch msg.origin { + case viewRepeater: + m.repeaterView.errMsg = "" + m.repeaterView.statusMsg = text + case viewResults: + if a := m.currentAttack(); a != nil { + a.results.saveMsg = text + } + case viewScanner: + m.scannerVw.errMsg = "" + m.scannerVw.statusMsg = text + } + return m, nil + + case saveScopeMsg: + if m.projectStore == nil { + m.targetView.statusMsg = "" + m.targetView.errMsg = "нет открытого проекта — открой на вкладке Project (ctrl+o)" + return m, nil + } + return m, projectSaveScopeCmd(m.projectStore, msg.json) + + case projectMetaSavedMsg: + if msg.err != nil { + m.targetView.errMsg = msg.err.Error() + m.targetView.statusMsg = "" + } else { + m.targetView.errMsg = "" + m.targetView.statusMsg = "scope-правила сохранены в проект" + } + return m, nil + + case spinner.TickMsg: + // Спиннер должен анимироваться, даже если пользователь сейчас не + // на вкладке Spider — иначе переключение вкладок оборвало бы цепочку + // тиков насовсем (bubbles/spinner не переиздаёт Tick сам по себе, + // это забота вызывающего кода). Как только running становится + // false, мы просто перестаём переиздавать команду — цепочка + // естественным образом затухает без явного "стоп"-сигнала. + if !m.spiderView.running { + return m, nil + } + var cmd tea.Cmd + m.spiderView.spinner, cmd = m.spiderView.spinner.Update(msg) + return m, cmd + + case dashboardTickMsg: + m.dashboardVw.SetContent(m.renderDashboard()) + return m, dashboardTickCmd() + + case resultsTickMsg: + if a := m.currentAttack(); a != nil { + a.results.Commit() + } + return m, resultsTickCmd() + } + + // Всё, что не перехвачено выше (обычный ввод текста, стрелки, + // прокрутка), уходит в активную вкладку. + var cmd tea.Cmd + switch m.active { + case viewRequestBuilder: + m.builder, cmd = m.builder.Update(msg) + case viewResults: + if a := m.currentAttack(); a != nil { + a.results, cmd = a.results.Update(msg) + } + case viewProxy: + m.proxyView, cmd = m.proxyView.Update(msg) + case viewRepeater: + m.repeaterView, cmd = m.repeaterView.Update(msg) + case viewSpider: + m.spiderView, cmd = m.spiderView.Update(msg) + case viewTarget: + m.targetView, cmd = m.targetView.Update(msg) + case viewDecoder: + m.decoderView, cmd = m.decoderView.Update(msg) + case viewComparer: + m.comparerView, cmd = m.comparerView.Update(msg) + case viewSequencer: + m.sequencerVw, cmd = m.sequencerVw.Update(msg) + case viewDashboard: + m.dashboardVw, cmd = m.dashboardVw.Update(msg) + case viewScanner: + m.scannerVw, cmd = m.scannerVw.Update(msg) + case viewProject: + m.projectView, cmd = m.projectView.Update(msg) + case viewFuzzer: + m.fuzzerVw, cmd = m.fuzzerVw.Update(msg) + } + return m, cmd +} + +// currentAttack возвращает атаку, видимую сейчас на вкладке Results, либо +// nil, если атак ещё не было ни одной. +func (m Model) currentAttack() *attackRun { + if m.activeAttack < 0 || m.activeAttack >= len(m.attacks) { + return nil + } + return m.attacks[m.activeAttack] +} + +// waitForResult — команда, блокирующаяся на чтении одного значения из +// канала результатов конкретной атаки (id). После обработки каждого +// resultMsg Update снова вызывает waitForResult с тем же id и каналом, +// поддерживая непрерывное чтение, пока канал не закроется (resultsDoneMsg). +// Несколько таких циклов работают параллельно — по одному на каждую +// активную атаку, что и даёт настоящую параллельность на уровне TUI. +func waitForResult(id int, ch <-chan engine.Result) tea.Cmd { + return func() tea.Msg { + r, ok := <-ch + if !ok { + return resultsDoneMsg{attackID: id} + } + return resultMsg{attackID: id, result: r} + } +} + +// waitForSpiderPage — тот же паттерн непрерывного чтения канала, что и +// waitForResult/waitForProxyEntry, но для потока найденных страниц Spider'а. +func waitForSpiderPage(ch <-chan spider.Page) tea.Cmd { + return func() tea.Msg { + p, ok := <-ch + if !ok { + return spiderDoneMsg{} + } + return spiderPageMsg(p) + } +} + +// waitForScanEvent — тот же паттерн непрерывного чтения канала, что и +// waitForResult/waitForSpiderPage, но для потока событий Scanner'а: +// разбирает Event.Kind и превращает его либо в scanFindingMsg, либо +// в scanProgressMsg — оба типа единообразно продолжают цикл ожидания. +func waitForScanEvent(ch <-chan scanner.Event) tea.Cmd { + return func() tea.Msg { + e, ok := <-ch + if !ok { + return scanDoneMsg{} + } + if e.Kind == scanner.EventFinding { + return scanFindingMsg(e.Finding) + } + return scanProgressMsg{done: e.Done, total: e.Total} + } +} + +// waitForFuzzEvent — тот же паттерн непрерывного чтения канала, что +// и waitForScanEvent, но для потока событий Fuzzer'а. +func waitForFuzzEvent(ch <-chan fuzzer.Event) tea.Cmd { + return func() tea.Msg { + e, ok := <-ch + if !ok { + return fuzzDoneMsg{} + } + if e.Kind == fuzzer.EventCrash { + return fuzzCrashMsg(e.Crash) + } + return fuzzProgressMsg{execs: e.Execs} + } +} + +// projectAutoSaveCmd сохраняет перехваченный Proxy-запрос в открытый +// проект. Вынесено в отдельную команду (а не вызов store.Insert прямо +// в Update) по той же причине, по которой в приложении вообще любой +// диск/сеть идёт через tea.Cmd: запись в SQLite — это I/O, пусть и +// быстрое, и не должна блокировать event loop TUI. +// maxRecursionBranches ограничивает, сколько под-атак запускается с +// одного завершившегося прохода — без этой узды сервер, отвечающий +// "200 OK" на любой путь без разбора (нередкая конфигурация SPA с +// фоллбэком на index.html), мог бы породить лавину под-атак: на каждом +// уровне рекурсии их число росло бы примерно как (совпадений на +// уровне)^(глубина), а не линейно. +const maxRecursionBranches = 25 + +// spawnRecursiveAttacks сканирует результаты завершившейся атаки на +// предмет "похоже на директорию" (совпадение со meta.statusPred) и +// запускает по под-атаке на каждую уникальную находку — параллельно, +// через tea.Batch, а не последовательно одну за другой. +func spawnRecursiveAttacks(meta recursionMeta, results []engine.Result) tea.Cmd { + var cmds []tea.Cmd + seen := make(map[string]bool) + + for _, r := range results { + if len(cmds) >= maxRecursionBranches { + break + } + if r.Err != nil || len(r.Payload) == 0 { + continue + } + if meta.statusPred != nil && !meta.statusPred(r.StatusCode) { + continue + } + word := r.Payload[0] + if word == "" || seen[word] { + continue + } + seen[word] = true + cmds = append(cmds, startRecursiveAttackCmd(meta, word)) + } + + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +// startRecursiveAttackCmd строит под-атаку внутри найденной директории +// discoveredWord: вставляет "discoveredWord/" перед точкой вставки в +// исходном сыром запросе (engine.InsertBeforeMarker) и пересобирает всё +// с нуля тем же путём, что и requestBuilderModel.runCmd — свежие +// генераторы из тех же specs, тот же движок с теми же параметрами. +func startRecursiveAttackCmd(meta recursionMeta, discoveredWord string) tea.Cmd { + return func() tea.Msg { + newRaw, err := engine.InsertBeforeMarker(meta.rawTemplate, meta.pointIndex, discoveredWord+"/") + if err != nil { + return errMsg{fmt.Errorf("рекурсия: %w", err)} + } + + clean, points, err := engine.ParseMarkers(newRaw) + if err != nil { + return errMsg{fmt.Errorf("рекурсия: маркеры §...§: %w", err)} + } + if meta.pointIndex >= len(points) { + return errMsg{fmt.Errorf("рекурсия: индекс точки вставки вышел за границы после подстановки")} + } + + gens := make([]payload.Generator, 0, len(meta.specs)) + for _, spec := range meta.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'а")} + } + + eng := engine.New(engine.Config{ + Concurrency: meta.concurrency, + RateLimit: meta.rateLimit, + Timeout: meta.timeout, + InsecureSkipVerify: meta.insecure, + ForceScheme: meta.forceScheme, + }) + + base := make([]string, len(points)) + total := engine.EstimateTotal(meta.mode, points, gens) + ctx, cancel := context.WithCancel(context.Background()) + ch, err := eng.Run(ctx, meta.mode, clean, points, gens, base) + if err != nil { + cancel() + return errMsg{fmt.Errorf("рекурсия: %w", err)} + } + + nextMeta := meta + nextMeta.rawTemplate = newRaw + nextMeta.depth = meta.depth + 1 + + return attackStartedMsg{ch: ch, cancel: cancel, total: total, recursion: &nextMeta} + } +} + +func projectAutoSaveCmd(store *project.Store, entry proxy.Entry) tea.Cmd { + return func() tea.Msg { + host := "" + if u, err := url.Parse(entry.URL); err == nil { + host = u.Host + } + _, err := store.Insert(project.Record{ + Source: "proxy", + Host: host, + Method: entry.Method, + URL: entry.URL, + Status: entry.StatusCode, + ContentLength: entry.Length, + RawRequest: entry.Raw, + RawResponse: entry.ResponseBody, + }) + if err != nil { + return projectErrMsg{fmt.Errorf("автосохранение в проект: %w", err)} + } + return nil + } +} + +// projectSaveCmd (ctrl+p на Repeater/Results/Scanner) сохраняет пачку +// записей в открытый проект. Продолжает сохранять остальные записи, +// даже если одна не прошла — не хочется терять весь батч из-за одной +// плохой строки; первую встреченную ошибку возвращает для показа +// пользователю, но она не блокирует остальные вставки. +func projectSaveCmd(store *project.Store, origin view, records []project.Record) tea.Cmd { + return func() tea.Msg { + saved := 0 + var firstErr error + for _, r := range records { + if _, err := store.Insert(r); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + saved++ + } + return projectSaveResultMsg{origin: origin, saved: saved, total: len(records), err: firstErr} + } +} + +// applyProjectScope пытается загрузить scope-правила из meta[scope_rules] +// проекта и применить их к targetView. Тихо ничего не делает, если +// правил нет (новый проект) или запись повреждена/от несовместимой +// версии — это не должно ронять открытие проекта, просто scope +// останется пустым, как для нового проекта. Переиспользуется и при +// открытии через ctrl+o (projectOpenedMsg), и при автозагрузке +// последнего проекта в New(). +func applyProjectScope(tv *targetModel, store *project.Store) { + data, ok, err := store.LoadMeta("scope_rules") + if err != nil || !ok { + return + } + var rules []sitemap.Rule + if err := json.Unmarshal([]byte(data), &rules); err != nil { + return + } + newScope := sitemap.NewScope() + for _, r := range rules { + _ = newScope.Add(r.Pattern, r.Exclude) + } + tv.scope = newScope + tv.rebuildTree() +} + +// pushScopeToProxy пересобирает независимый снапшот из scope.Rules() и +// передаёт его в живой proxy.Server.SetScope. Снапшот, а не тот же +// *sitemap.Scope, что мутирует targetModel — иначе TUI-горутина (Add/ +// RemoveAt, обычный append по срезу) и горутины прокси (Matches, чтение +// того же среза на каждый запрос) конкурировали бы за один объект без +// синхронизации. Не паникует и просто ничего не делает, если server==nil +// (прокси не поднялся) — тот же принцип терпимости к отсутствующему +// прокси, что и в остальном приложении. +func pushScopeToProxy(server *proxy.Server, scope *sitemap.Scope) { + if server == nil { + return + } + snapshot := sitemap.NewScope() + for _, r := range scope.Rules() { + _ = snapshot.Add(r.Pattern, r.Exclude) + } + server.SetScope(snapshot) +} + +// projectSaveScopeCmd (ctrl+p на Target) пишет сериализованные +// scope-правила в meta[scope_rules] открытого проекта. +func projectSaveScopeCmd(store *project.Store, jsonData string) tea.Cmd { + return func() tea.Msg { + if err := store.SaveMeta("scope_rules", jsonData); err != nil { + return projectMetaSavedMsg{err: err} + } + return projectMetaSavedMsg{} + } +} + +// waitForProxyEntry — тот же паттерн непрерывного чтения канала, +// что и waitForResult, но для потока перехваченных прокси-запросов, +// который живёт всё время работы TUI, а не только во время атаки. +func waitForProxyEntry(ch <-chan proxy.Entry) tea.Cmd { + return func() tea.Msg { + e, ok := <-ch + if !ok { + return nil + } + return proxyEntryMsg(e) + } +} + +func (m Model) View() string { + tabs := renderTabs(m.active, m.builder.IsModified(), m.repeaterView.IsModified()) + var body string + switch m.active { + case viewRequestBuilder: + body = m.builder.View() + case viewResults: + body = m.resultsView() + case viewProxy: + body = fmt.Sprintf( + "Прокси: %s CA: %s (импортируй один раз в браузер)\n\n%s", + m.proxyAddr, m.caPath, m.proxyView.View(), + ) + case viewRepeater: + body = m.repeaterView.View() + case viewSpider: + body = m.spiderView.View() + case viewTarget: + body = m.targetView.View() + case viewDecoder: + body = m.decoderView.View() + case viewComparer: + body = m.comparerView.View() + case viewSequencer: + body = m.sequencerVw.View() + case viewDashboard: + body = m.dashboardVw.View() + case viewScanner: + body = m.scannerVw.View() + case viewProject: + body = m.projectView.View() + case viewFuzzer: + body = m.fuzzerVw.View() + } + + // Раньше здесь body каждой вкладки (кроме Dashboard) оборачивалась + // в panel() — общая рамка вокруг всего тела. Откатил: рамка добавляет + // 4 строки "хрома" (верхняя граница + строка заголовка + нижняя + // граница + разделитель), и я дважды подряд не смог вслепую подобрать + // для неё правильный бюджет высоты — то меню внизу вкладки обрезалось, + // то сама рамка вылезала за край экрана на вкладках с длинным + // содержимым (Comparer, Scanner). Без реального терминала под рукой + // подбор точных чисел — угадывание, а тут уже дважды не угадал. + // Dashboard со своими собственными панелями (там это подтверждённо + // работает) и стилизованный таб-бар (renderTabs) остаются — они не + // зависят от этого бюджета так жёстко. + return tabs + "\n\n" + body +} + +// resultsView рендерит вкладку Results: если атак ещё не было — подсказку, +// иначе заголовок с номером текущей атаки среди всех и её resultsModel. +func (m Model) resultsView() string { + if len(m.attacks) == 0 { + return "Атак ещё не было. Заполни форму на вкладке Request и нажми ctrl+r." + } + + a := m.attacks[m.activeAttack] + status := "выполняется" + if !a.running { + status = "завершена" + } + header := fmt.Sprintf( + "Атака #%d (%d/%d вкладок, %s, %d%s результатов)\n[ / ] — переключение между атаками · esc — отменить текущую\n\n", + a.id, m.activeAttack+1, len(m.attacks), status, len(a.results.all), progressSuffix(len(a.results.all), a.total), + ) + return header + a.results.View() +} + +// progressSuffix форматирует "N/Total (XX%)" — если total неизвестен +// (0, напр. для нулевого генератора — на практике не бывает после +// валидации в форме, но на всякий случай), просто ничего не добавляет. +func progressSuffix(done, total int) string { + if total <= 0 { + return "" + } + pct := float64(done) / float64(total) * 100 + if pct > 100 { + pct = 100 + } + return fmt.Sprintf("/%d (%.0f%%)", total, pct) +} + +// renderDashboard собирает метрики самого процесса burterm: память и +// горутины из runtime, состояние всех активных Intruder-атак (с грубым +// req/s — результаты минус время с запуска, без скользящего окна), +// счётчик перехваченного Proxy-трафика и статус Spider. Это метрики +// самого инструмента, а не хоста — реальный CPU% системы потребовал бы +// платформенно-специфичного чтения /proc/stat, что осознанно не стали +// делать ради переносимости этого файла. +func (m Model) renderDashboard() string { + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + width := m.width + if width <= 0 { + width = 80 + } + + var processBody strings.Builder + processBody.WriteString(fmt.Sprintf("Аптайм: %s\n", time.Since(m.programStart).Round(time.Second))) + processBody.WriteString(fmt.Sprintf("Горутин: %d\n", runtime.NumGoroutine())) + processBody.WriteString(fmt.Sprintf("Heap alloc: %s\n", humanBytes(mem.HeapAlloc))) + processBody.WriteString(fmt.Sprintf("Sys (от ОС): %s\n", humanBytes(mem.Sys))) + processBody.WriteString(fmt.Sprintf("Аллокаций: %s\n", humanBytes(mem.TotalAlloc))) + processBody.WriteString(fmt.Sprintf("GC-циклов: %d", mem.NumGC)) + + var intruderBody strings.Builder + if len(m.attacks) == 0 { + intruderBody.WriteString("(атак ещё не было)") + } else { + for i, a := range m.attacks { + status := "выполняется" + if !a.running { + status = "завершена" + } + elapsed := time.Since(a.startedAt) + count := len(a.results.all) + rps := 0.0 + if elapsed.Seconds() > 0 { + rps = float64(count) / elapsed.Seconds() + } + if i > 0 { + intruderBody.WriteByte('\n') + } + intruderBody.WriteString(fmt.Sprintf("#%-3d %-11s %d%s %.1f/с", + a.id, status, count, progressSuffix(count, a.total), rps)) + } + } + + // Двухколоночная раскладка только на достаточно широком терминале — + // на мобильном SSH-клиенте панели бок о бок просто не влезут и + // съедутся, поэтому ниже порога честно складываем их вертикально. + twoCol := width >= 90 + var row1 string + if twoCol { + colWidth := width/2 - 6 + if colWidth < 20 { + colWidth = 20 + } + row1 = lipgloss.JoinHorizontal(lipgloss.Top, + panel("process", processBody.String(), colWidth), + panel("intruder", intruderBody.String(), colWidth), + ) + } else { + fullWidth := width - 6 + if fullWidth < 20 { + fullWidth = 20 + } + row1 = lipgloss.JoinVertical(lipgloss.Left, + panel("process", processBody.String(), fullWidth), + panel("intruder", intruderBody.String(), fullWidth), + ) + } + + var modulesBody strings.Builder + modulesBody.WriteString(fmt.Sprintf("Proxy перехвачено: %d\n", len(m.proxyView.entries))) + + spiderStatus := "не запущен" + if m.spiderView.running { + spiderStatus = "выполняется" + } + modulesBody.WriteString(fmt.Sprintf("Spider %-12s страниц: %d\n", spiderStatus, m.spiderView.count)) + + scanStatus := "не запущен" + if m.scannerVw.running { + scanStatus = "выполняется" + } + modulesBody.WriteString(fmt.Sprintf("Scanner %-12s находок: %d\n", scanStatus, m.scannerVw.count)) + + if m.projectStore != nil { + count, err := m.projectStore.Count() + if err != nil { + modulesBody.WriteString(fmt.Sprintf("Project %s (ошибка подсчёта: %v)\n", m.projectStore.Path, err)) + } else { + modulesBody.WriteString(fmt.Sprintf("Project %s, записей: %d\n", m.projectStore.Path, count)) + } + } else { + modulesBody.WriteString("Project не открыт\n") + } + + fuzzStatus := "не запущен" + if m.fuzzerVw.running { + fuzzStatus = "выполняется" + } + modulesBody.WriteString(fmt.Sprintf("Fuzzer %-12s execs: %d, крашей: %d", fuzzStatus, m.fuzzerVw.execs, m.fuzzerVw.crashes)) + + modulesWidth := width - 6 + if modulesWidth < 20 { + modulesWidth = 20 + } + modulesPanel := panel("modules", modulesBody.String(), modulesWidth) + + var out strings.Builder + out.WriteString(renderLogo()) + out.WriteString("\n\n") + out.WriteString(row1) + out.WriteString("\n") + out.WriteString(modulesPanel) + return out.String() +} + +// humanBytes форматирует байты в KiB/MiB/GiB — обычный идиоматичный для +// Go способ, без внешних зависимостей. +func humanBytes(n uint64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := uint64(unit), 0 + for n2 := n / unit; n2 >= unit; n2 /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/tui/projectview.go b/internal/tui/projectview.go new file mode 100644 index 0000000..57140e5 --- /dev/null +++ b/internal/tui/projectview.go @@ -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() +} diff --git a/internal/tui/proxyview.go b/internal/tui/proxyview.go new file mode 100644 index 0000000..e9b1407 --- /dev/null +++ b/internal/tui/proxyview.go @@ -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(), + ) +} diff --git a/internal/tui/repeater.go b/internal/tui/repeater.go new file mode 100644 index 0000000..1384c28 --- /dev/null +++ b/internal/tui/repeater.go @@ -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() +} diff --git a/internal/tui/requestbuilder.go b/internal/tui/requestbuilder.go new file mode 100644 index 0000000..d11ca10 --- /dev/null +++ b/internal/tui/requestbuilder.go @@ -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" +} diff --git a/internal/tui/results.go b/internal/tui/results.go new file mode 100644 index 0000000..5165a64 --- /dev/null +++ b/internal/tui/results.go @@ -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() +} diff --git a/internal/tui/scannerview.go b/internal/tui/scannerview.go new file mode 100644 index 0000000..befde28 --- /dev/null +++ b/internal/tui/scannerview.go @@ -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() +} diff --git a/internal/tui/sequencerview.go b/internal/tui/sequencerview.go new file mode 100644 index 0000000..01e152a --- /dev/null +++ b/internal/tui/sequencerview.go @@ -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() +} diff --git a/internal/tui/spiderview.go b/internal/tui/spiderview.go new file mode 100644 index 0000000..8144741 --- /dev/null +++ b/internal/tui/spiderview.go @@ -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() +} diff --git a/internal/tui/style.go b/internal/tui/style.go new file mode 100644 index 0000000..b9fd429 --- /dev/null +++ b/internal/tui/style.go @@ -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 +} diff --git a/internal/tui/targetview.go b/internal/tui/targetview.go new file mode 100644 index 0000000..37c5e93 --- /dev/null +++ b/internal/tui/targetview.go @@ -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-.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() +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..239d7fd --- /dev/null +++ b/internal/tui/theme.go @@ -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) +}