Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions pkg/tui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# TruffleHog TUI

The TUI is a Bubble Tea program that lets the user configure a scan or an
analyzer interactively. When it exits, control hands back to `kingpin` (for
scans) or to `pkg/analyzer.Run` (for analyzer flows).

## Architecture

Three layers, nothing else:

1. **`app` — the router.** Owns the page stack, global key handling, the
shared style sheet and key bindings, and the parent-process handoff
(argv for kingpin, SecretInfo for the analyzer). Pages know nothing
about each other; they emit navigation messages and the router
reacts.

2. **`pages/*` — one directory per page.** Every page implements
`app.Page`, receives `app.ResizeMsg` for layout, and communicates
upward with `app.PushMsg` / `app.PopMsg` / `app.ReplaceMsg` /
`app.ExitMsg` / `app.RunAnalyzerMsg`. Layout is kebab-case
(`pages/source-picker/`, `pages/analyzer-form/`, …).

3. **`components/form` + `sources` — declarative configuration.**
`sources.Definition` is a registry entry for each scan source:
title, description, `form.FieldSpec`s, validators, optional
`BuildArgs` override. `form.Form` renders the fields, validates
them, and produces a kingpin-style `[]string` arg vector. The few
sources with irreducibly complex arg-emission rules (elasticsearch,
jenkins, postman) provide a `BuildArgs` hook.

```mermaid
graph TD
subgraph Runtime
main[main.go] -->|argv| tui[pkg/tui.Run]
end

subgraph pkg/tui
tui -->|builds| Model[app.Model router]
Model --> Pages[pages/*]
Pages -->|uses| Form[components/form]
Pages -->|uses| Registry[sources registry]
end

Model -->|ExitMsg| kingpin
Model -->|RunAnalyzerMsg| analyzer.Run
```

## Workflows

### Entry

```mermaid
flowchart LR
start([main.go]) --> argv{argv}
argv -->|no args| wizard[wizard]
argv -->|analyze| picker[analyzer-picker]
argv -->|analyze known| form[analyzer-form]
argv -->|analyze unknown| picker
```

### Source scan

```mermaid
sequenceDiagram
participant U as user
participant W as wizard
participant SP as source-picker
participant SC as source-config
participant R as router
participant K as kingpin

U->>W: choose "Scan a source"
W->>R: PushMsg(source-picker)
U->>SP: pick source
SP->>R: PushMsg(source-config, SourceID)
U->>SC: fill source tab → tab
U->>SC: fill trufflehog tab → tab
U->>SC: enter on run tab
SC->>R: ExitMsg(argv)
R->>K: argv
```

### Analyzer

```mermaid
sequenceDiagram
participant U as user
participant AP as analyzer-picker
participant AF as analyzer-form
participant R as router
participant A as analyzer.Run

U->>AP: pick analyzer type
AP->>R: PushMsg(analyzer-form, KeyType)
U->>AF: fill fields
AF->>R: RunAnalyzerMsg(type, info)
R->>A: dispatch
```

## Adding a new source

1. Create `pkg/tui/sources/<id>/` with a `Definition()` returning a
`sources.Definition`.
2. Declare fields with `form.FieldSpec` — pick the appropriate `Kind`
(`KindText`, `KindSecret`, `KindCheckbox`, `KindSelect`) and
`EmitMode` (`EmitPositional`, `EmitLongFlagEq`, `EmitPresence`,
`EmitRepeatedLongFlagEq`, `EmitConstant`, …). Add validators from
`form` (`Required`, `Integer`, `OneOf`) and cross-field
`Constraint`s (`XOrGroup`) as needed.
3. If the arg-emission logic isn't expressible declaratively, set
`Definition.BuildArgs` to a `func(values map[string]string) []string`.
4. Add `func init() { sources.Register(Definition()) }` and blank-import
the package from `pkg/tui/tui.go` so the registry picks it up.
5. Add a test case to `pkg/tui/sources/sources_test.go` covering the
emitted arg vector.

## Adding a new page

1. Create `pkg/tui/pages/<kebab-name>/` with a `Page` type satisfying
`app.Page`.
2. Export an `ID` constant re-declaring the appropriate `app.PageID` (or
add a new one to `pkg/tui/app/page.go`).
3. Register a factory in `pkg/tui/tui.go`'s `registerPages`.
4. Emit `app.PushMsg` / `app.PopMsg` / `app.ExitMsg` /
`app.RunAnalyzerMsg` from the page as appropriate; never touch the
stack directly.
244 changes: 244 additions & 0 deletions pkg/tui/app/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package app

import (
"fmt"

"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"

"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer"
"github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme"
)

// Minimum terminal size below which the router refuses to render any page.
const (
minWidth = 40
minHeight = 10
)

// Model is the TUI router. It owns the navigation stack, chrome, global keys
// and the parent-process handoff. Pages know nothing about each other and
// only communicate through the navigation messages in messages.go.
type Model struct {
styles *theme.Styles
keymap *theme.KeyMap
zone *zone.Manager
factories map[PageID]Factory
stack []Page
width int
height int

// Deliverables passed back to pkg/tui.Run once the program exits.
args []string
analyzerType string
analyzerInfo analyzer.SecretInfo

// initial page + data, resolved on first Update after WindowSizeMsg so
// pages are created with a valid size from the start.
initialID PageID
initialData any
initialized bool
}

// New constructs a Model with default styles, keymap and zone manager. The
// caller is responsible for registering page factories with Register before
// calling Run, and for seeding the initial page with SetInitialPage.
func New() *Model {
return &Model{
styles: theme.DefaultStyles(),
keymap: theme.DefaultKeyMap(),
zone: zone.New(),
factories: make(map[PageID]Factory),
}
}

// Register associates a PageID with a factory. Calling Register twice with
// the same ID panics; pages are expected to be registered once at startup.
func (m *Model) Register(id PageID, f Factory) {
if _, ok := m.factories[id]; ok {
panic(fmt.Sprintf("app: duplicate page factory for %q", id))
}
m.factories[id] = f
}

// SetInitialPage seeds the page the router should push on the first tick.
// The page is not constructed until the first tea.WindowSizeMsg so it sees a
// valid size from Init.
func (m *Model) SetInitialPage(id PageID, data any) {
m.initialID = id
m.initialData = data
}

// Styles returns the shared style sheet. Pages use this via the constructor
// dependency injected by their Factory.
func (m *Model) Styles() *theme.Styles { return m.styles }

// Keymap returns the shared keymap.
func (m *Model) Keymap() *theme.KeyMap { return m.keymap }

// Zone returns the shared bubblezone manager.
func (m *Model) Zone() *zone.Manager { return m.zone }

// Args is the arg vector to hand to kingpin on re-exec. nil means "user
// quit".
func (m *Model) Args() []string { return m.args }

// AnalyzerType is the lowercased analyzer name if the user completed an
// analyzer flow, empty string otherwise.
func (m *Model) AnalyzerType() string { return m.analyzerType }

// AnalyzerInfo carries the analyzer form submission, only valid when
// AnalyzerType() is non-empty.
func (m *Model) AnalyzerInfo() analyzer.SecretInfo { return m.analyzerInfo }

// Init implements tea.Model.
func (m *Model) Init() tea.Cmd { return nil }

func (m *Model) active() Page {
if len(m.stack) == 0 {
return nil
}
return m.stack[len(m.stack)-1]
}

func (m *Model) contentSize() (int, int) {
// Router-owned chrome is the App style's frame size. The content
// rectangle is whatever's left.
hFrame, vFrame := m.styles.App.GetFrameSize()
w := m.width - hFrame
h := m.height - vFrame
if w < 0 {
w = 0
}
if h < 0 {
h = 0
}
return w, h
}

func (m *Model) resizeActive() tea.Cmd {
p := m.active()
if p == nil {
return nil
}
w, h := m.contentSize()
p.SetSize(w, h)
resize := ResizeMsg{Width: w, Height: h}
_, cmd := p.Update(resize)
return cmd
}

func (m *Model) push(id PageID, data any) tea.Cmd {
f, ok := m.factories[id]
if !ok {
panic(fmt.Sprintf("app: no factory registered for page %q", id))
}
page := f(data)
m.stack = append(m.stack, page)
initCmd := page.Init()
resizeCmd := m.resizeActive()
return tea.Batch(initCmd, resizeCmd)
}

func (m *Model) pop() tea.Cmd {
if len(m.stack) == 0 {
return tea.Quit
}
m.stack = m.stack[:len(m.stack)-1]
if len(m.stack) == 0 {
return tea.Quit
}
return m.resizeActive()
}

func (m *Model) replace(id PageID, data any) tea.Cmd {
if len(m.stack) > 0 {
m.stack = m.stack[:len(m.stack)-1]
}
return m.push(id, data)
}

// Update implements tea.Model.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
if !m.initialized {
m.initialized = true
if m.initialID == "" {
return m, tea.Quit
}
return m, m.push(m.initialID, m.initialData)
}
return m, m.resizeActive()

case tea.KeyMsg:
if cmd := m.handleGlobalKey(msg); cmd != nil {
return m, cmd
}

case PushMsg:
return m, m.push(msg.ID, msg.Data)

case PopMsg:
return m, m.pop()

case ReplaceMsg:
return m, m.replace(msg.ID, msg.Data)

case ExitMsg:
m.args = msg.Args
return m, tea.Quit

case RunAnalyzerMsg:
m.analyzerType = msg.Type
m.analyzerInfo = msg.Info
return m, tea.Quit
}

// Forward everything else to the active page.
if p := m.active(); p != nil {
next, cmd := p.Update(msg)
m.stack[len(m.stack)-1] = next
return m, cmd
}
return m, nil
}

func (m *Model) handleGlobalKey(msg tea.KeyMsg) tea.Cmd {
switch {
case key.Matches(msg, m.keymap.Quit):
m.args = nil
return tea.Quit
case key.Matches(msg, m.keymap.CmdQuit):
p := m.active()
if p != nil && len(m.stack) == 1 && p.AllowQKey() {
m.args = nil
return tea.Quit
}
case key.Matches(msg, m.keymap.Back):
if len(m.stack) <= 1 {
m.args = nil
return tea.Quit
}
return m.pop()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Router intercepts esc key, breaking list filter cancellation

High Severity

The router's handleGlobalKey unconditionally intercepts the esc key (bound to Back) and pops the page or quits — before the message ever reaches the active page. Both the source picker and analyzer picker use bubbles/list with filtering enabled, which relies on esc to cancel an active filter. When a user activates the filter and presses esc to dismiss it, the router pops the page instead, making list filtering effectively unusable.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 637df36. Configure here.

}
return nil
}

// View implements tea.Model.
func (m *Model) View() string {
if m.width < minWidth || m.height < minHeight {
return m.styles.App.Render(fmt.Sprintf(
"Terminal too small — need at least %d×%d, got %d×%d",
minWidth, minHeight, m.width, m.height,
))
}
p := m.active()
if p == nil {
return ""
}
return m.zone.Scan(m.styles.App.Render(p.View()))
}
Loading
Loading