burterm/internal/tui/scannerview.go
2026-09-14 10:55:07 +03:00

447 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}