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