447 lines
14 KiB
Go
447 lines
14 KiB
Go
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()
|
||
}
|