diff --git a/pkg/tui/README.md b/pkg/tui/README.md new file mode 100644 index 000000000000..cafe7d9152d8 --- /dev/null +++ b/pkg/tui/README.md @@ -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//` 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//` 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. diff --git a/pkg/tui/app/app.go b/pkg/tui/app/app.go new file mode 100644 index 000000000000..00d3ff8a0302 --- /dev/null +++ b/pkg/tui/app/app.go @@ -0,0 +1,250 @@ +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, handled := m.handleGlobalKey(msg); handled { + 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 +} + +// handleGlobalKey inspects the key against the router-level bindings and +// returns (cmd, handled). When handled is true the caller MUST NOT forward the +// key to the active page, even if cmd is nil: bubbles/list (and other +// widgets) treat esc/q as "quit", so forwarding a consumed Back key after a +// successful pop would immediately tear the program down. +func (m *Model) handleGlobalKey(msg tea.KeyMsg) (tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keymap.Quit): + m.args = nil + return tea.Quit, true + 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, true + } + return nil, false + case key.Matches(msg, m.keymap.Back): + if len(m.stack) <= 1 { + m.args = nil + return tea.Quit, true + } + return m.pop(), true + } + return nil, false +} + +// 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())) +} diff --git a/pkg/tui/app/messages.go b/pkg/tui/app/messages.go new file mode 100644 index 000000000000..bc2d3fcaddfd --- /dev/null +++ b/pkg/tui/app/messages.go @@ -0,0 +1,47 @@ +package app + +import "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer" + +// Navigation and lifecycle messages. Pages emit these from their Update +// methods to drive routing; the router is the only place these are handled. + +// PushMsg pushes a new page onto the navigation stack. Data is passed to the +// target page's factory as the initial payload (may be nil). +type PushMsg struct { + ID PageID + Data any +} + +// PopMsg pops the current page. If the stack is empty after the pop, the app +// exits. +type PopMsg struct{} + +// ReplaceMsg pops the current page and pushes a new one atomically. This is +// useful for "move forward and don't let the user come back here" flows +// (e.g. wizard → picker). +type ReplaceMsg struct { + ID PageID + Data any +} + +// ExitMsg tells the router to hand Args back to the parent process and quit. +// An Args of nil means "user quit" — main.go will call os.Exit(0) directly. +type ExitMsg struct { + Args []string +} + +// RunAnalyzerMsg tells the router to quit the TUI and hand off to +// analyzer.Run, bypassing kingpin entirely. +type RunAnalyzerMsg struct { + Type string + Info analyzer.SecretInfo +} + +// ResizeMsg is the router-managed replacement for tea.WindowSizeMsg. The +// router computes the content rectangle (terminal size minus chrome) once +// and passes it to the active page so pages can't drift on their own frame +// math. +type ResizeMsg struct { + Width int + Height int +} diff --git a/pkg/tui/app/page.go b/pkg/tui/app/page.go new file mode 100644 index 000000000000..7df80aae2f9b --- /dev/null +++ b/pkg/tui/app/page.go @@ -0,0 +1,56 @@ +// Package app hosts the TUI router: a single model that owns a navigation +// stack of Pages and centralizes chrome, resize and global keys. +// +// The old pkg/tui.TUI god-model is replaced by app.Model. Pages no longer +// know about each other — they emit navigation messages (see messages.go) and +// the router takes care of pushing, popping, and handing off to the parent +// process. +package app + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +// PageID is the stable identifier used to register and look up pages. +type PageID string + +// Well-known page IDs. Each page package exports its own ID constant as well; +// these are re-declared here so the router can reference them without +// importing every page package. +const ( + PageWizard PageID = "wizard" + PageSourcePicker PageID = "source-picker" + PageSourceConfig PageID = "source-config" + PageAnalyzerPicker PageID = "analyzer-picker" + PageAnalyzerForm PageID = "analyzer-form" + PageLinkCard PageID = "link-card" +) + +// Page is the uniform contract every TUI page implements. +// +// The interface intentionally omits bubbles/help.KeyMap — pages expose only a +// short key binding list and the router renders the help line itself. +type Page interface { + ID() PageID + Init() tea.Cmd + Update(tea.Msg) (Page, tea.Cmd) + View() string + // SetSize receives the content rectangle computed by the router, i.e. + // the terminal size minus any chrome the router owns. + SetSize(width, height int) + // Help returns the short help bindings to display at the bottom of the + // screen. nil is valid and means "no page-specific help". + Help() []key.Binding + // AllowQKey returns true if the `q` key should quit the app while this + // page is the only entry on the stack. Text-entry pages should return + // false so users can type the letter q. + AllowQKey() bool +} + +// Factory lazily constructs a page given optional navigation data. +// +// Pages are registered on the Model at construction time; the router calls +// the factory on every PushMsg so that re-entering a page gets a fresh +// instance rather than stale state from the previous visit. +type Factory func(data any) Page diff --git a/pkg/tui/common/common.go b/pkg/tui/common/common.go deleted file mode 100644 index 9b8cf4a996f9..000000000000 --- a/pkg/tui/common/common.go +++ /dev/null @@ -1,24 +0,0 @@ -package common - -import ( - "github.com/aymanbagabas/go-osc52" - zone "github.com/lrstanley/bubblezone" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/keymap" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -// Common is a struct all components should embed. -type Common struct { - Copy *osc52.Output - Styles *styles.Styles - KeyMap *keymap.KeyMap - Width int - Height int - Zone *zone.Manager -} - -// SetSize sets the width and height of the common struct. -func (c *Common) SetSize(width, height int) { - c.Width = width - c.Height = height -} diff --git a/pkg/tui/common/component.go b/pkg/tui/common/component.go deleted file mode 100644 index ed8b9bf05cd3..000000000000 --- a/pkg/tui/common/component.go +++ /dev/null @@ -1,13 +0,0 @@ -package common - -import ( - "github.com/charmbracelet/bubbles/help" - tea "github.com/charmbracelet/bubbletea" -) - -// Component represents a Bubble Tea model that implements a SetSize function. -type Component interface { - tea.Model - help.KeyMap - SetSize(width, height int) -} diff --git a/pkg/tui/common/error.go b/pkg/tui/common/error.go deleted file mode 100644 index fe9729805622..000000000000 --- a/pkg/tui/common/error.go +++ /dev/null @@ -1,13 +0,0 @@ -package common - -import tea "github.com/charmbracelet/bubbletea" - -// ErrorMsg is a Bubble Tea message that represents an error. -type ErrorMsg error - -// ErrorCmd returns an ErrorMsg from error. -func ErrorCmd(err error) tea.Cmd { - return func() tea.Msg { - return ErrorMsg(err) - } -} diff --git a/pkg/tui/common/style.go b/pkg/tui/common/style.go deleted file mode 100644 index 2003869add3e..000000000000 --- a/pkg/tui/common/style.go +++ /dev/null @@ -1,28 +0,0 @@ -package common - -import ( - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/glamour/styles" -) - -func strptr(s string) *string { - return &s -} - -// StyleConfig returns the default Glamour style configuration. -func StyleConfig() gansi.StyleConfig { - noColor := strptr("") - s := styles.DarkStyleConfig - - s.H1.BackgroundColor = noColor - s.H1.Prefix = "# " - s.H1.Suffix = "" - s.H1.Color = strptr("39") - s.Document.StylePrimitive.Color = noColor - s.CodeBlock.Chroma.Text.Color = noColor - s.CodeBlock.Chroma.Name.Color = noColor - // This fixes an issue with the default style config. For example - // highlighting empty spaces with red in Dockerfile type. - s.CodeBlock.Chroma.Error.BackgroundColor = noColor - return s -} diff --git a/pkg/tui/common/utils.go b/pkg/tui/common/utils.go deleted file mode 100644 index 2d7e15d3f1c8..000000000000 --- a/pkg/tui/common/utils.go +++ /dev/null @@ -1,28 +0,0 @@ -package common - -import ( - "strings" - - "github.com/muesli/reflow/truncate" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" -) - -// TruncateString is a convenient wrapper around truncate.TruncateString. -func TruncateString(s string, max int) string { - if max < 0 { - max = 0 - } - return truncate.StringWithTail(s, uint(max), "…") -} - -func SummarizeSource(keys []string, inputs map[string]textinputs.Input, labels map[string]string) string { - summary := strings.Builder{} - for _, key := range keys { - if inputs[key].Value != "" { - summary.WriteString("\t" + labels[key] + ": " + inputs[key].Value + "\n") - } - } - - summary.WriteString("\n") - return summary.String() -} diff --git a/pkg/tui/components/confirm/confirm.go b/pkg/tui/components/confirm/confirm.go deleted file mode 100644 index 0ca4069697f7..000000000000 --- a/pkg/tui/components/confirm/confirm.go +++ /dev/null @@ -1,115 +0,0 @@ -package confirm - -import ( - "fmt" - "unicode" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" -) - -type Confirm struct { - common common.Common - message string - affirmativeChoice string - negativeChoice string - affirmativeUpdate func() (tea.Model, tea.Cmd) - negativeUpdate func() (tea.Model, tea.Cmd) - choice bool -} - -type Msg struct { - Choice bool - Message string -} - -type ConfirmOpt func(*Confirm) - -func WithAffirmativeMessage(msg string) ConfirmOpt { - return func(c *Confirm) { c.affirmativeChoice = msg } -} - -func WithNegativeMessage(msg string) ConfirmOpt { - return func(c *Confirm) { c.negativeChoice = msg } -} - -func WithDefault(choice bool) ConfirmOpt { - return func(c *Confirm) { c.choice = choice } -} - -func WithAffirmativeTransition(m tea.Model, cmd tea.Cmd) ConfirmOpt { - return func(c *Confirm) { - c.affirmativeUpdate = func() (tea.Model, tea.Cmd) { - return m, cmd - } - } -} - -func WithNegativeTransition(m tea.Model, cmd tea.Cmd) ConfirmOpt { - return func(c *Confirm) { - c.negativeUpdate = func() (tea.Model, tea.Cmd) { - return m, cmd - } - } -} - -func New(c common.Common, msg string, opts ...ConfirmOpt) Confirm { - confirm := Confirm{ - common: c, - message: msg, - affirmativeChoice: "Yes", - negativeChoice: "Cancel", - affirmativeUpdate: func() (tea.Model, tea.Cmd) { return nil, nil }, - negativeUpdate: func() (tea.Model, tea.Cmd) { return nil, nil }, - } - for _, opt := range opts { - opt(&confirm) - } - return confirm -} - -func (Confirm) Init() tea.Cmd { - return nil -} - -func (c Confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - keyMsg, ok := msg.(tea.KeyMsg) - if !ok { - return c, nil - } - switch { - case key.Matches(keyMsg, c.common.KeyMap.Left) || keyMatchesFirstChar(keyMsg, c.negativeChoice): - c.choice = false - case key.Matches(keyMsg, c.common.KeyMap.Right) || keyMatchesFirstChar(keyMsg, c.affirmativeChoice): - c.choice = true - case key.Matches(keyMsg, c.common.KeyMap.Select): - model, cmd := c.negativeUpdate() - if c.choice { - model, cmd = c.affirmativeUpdate() - } - - return model, cmd - } - return c, nil -} - -func (c Confirm) View() string { - var affirmative, negative string - if c.choice { - affirmative = fmt.Sprintf("[ %s ]", c.affirmativeChoice) - negative = fmt.Sprintf(" %s ", c.negativeChoice) - } else { - affirmative = fmt.Sprintf(" %s ", c.affirmativeChoice) - negative = fmt.Sprintf("[ %s ]", c.negativeChoice) - } - return fmt.Sprintf("%s\t%s\t%s", c.message, negative, affirmative) -} - -func keyMatchesFirstChar(msg tea.KeyMsg, s string) bool { - if s == "" { - return false - } - firstChar := rune(s[0]) - return msg.String() == string(unicode.ToLower(firstChar)) -} diff --git a/pkg/tui/components/footer/footer.go b/pkg/tui/components/footer/footer.go deleted file mode 100644 index 1ccf49071d86..000000000000 --- a/pkg/tui/components/footer/footer.go +++ /dev/null @@ -1,96 +0,0 @@ -package footer - -import ( - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" -) - -// ToggleFooterMsg is a message sent to show/hide the footer. -type ToggleFooterMsg struct{} - -// Footer is a Bubble Tea model that displays help and other info. -type Footer struct { - common common.Common - help help.Model - keymap help.KeyMap -} - -// New creates a new Footer. -func New(c common.Common, keymap help.KeyMap) *Footer { - h := help.New() - h.Styles.ShortKey = c.Styles.HelpKey - h.Styles.ShortDesc = c.Styles.HelpValue - h.Styles.FullKey = c.Styles.HelpKey - h.Styles.FullDesc = c.Styles.HelpValue - f := &Footer{ - common: c, - help: h, - keymap: keymap, - } - f.SetSize(c.Width, c.Height) - return f -} - -// SetSize implements common.Component. -func (f *Footer) SetSize(width, height int) { - f.common.SetSize(width, height) - f.help.Width = width - - f.common.Styles.Footer.GetHorizontalFrameSize() -} - -// Init implements tea.Model. -func (f *Footer) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (f *Footer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return f, nil -} - -// View implements tea.Model. -func (f *Footer) View() string { - if f.keymap == nil { - return "" - } - s := f.common.Styles.Footer. - Width(f.common.Width) - helpView := f.help.View(f.keymap) - return f.common.Zone.Mark( - "footer", - s.Render(helpView), - ) -} - -// ShortHelp returns the short help key bindings. -func (f *Footer) ShortHelp() []key.Binding { - return f.keymap.ShortHelp() -} - -// FullHelp returns the full help key bindings. -func (f *Footer) FullHelp() [][]key.Binding { - return f.keymap.FullHelp() -} - -// ShowAll returns whether the full help is shown. -func (f *Footer) ShowAll() bool { - return f.help.ShowAll -} - -// SetShowAll sets whether the full help is shown. -func (f *Footer) SetShowAll(show bool) { - f.help.ShowAll = show -} - -// Height returns the height of the footer. -func (f *Footer) Height() int { - return lipgloss.Height(f.View()) -} - -// ToggleFooterCmd sends a ToggleFooterMsg to show/hide the help footer. -func ToggleFooterCmd() tea.Msg { - return ToggleFooterMsg{} -} diff --git a/pkg/tui/components/form/args.go b/pkg/tui/components/form/args.go new file mode 100644 index 000000000000..39034048824a --- /dev/null +++ b/pkg/tui/components/form/args.go @@ -0,0 +1,59 @@ +package form + +import "strings" + +// BuildArgs renders a slice of FieldSpec + values into a kingpin-style arg +// vector. It is pure (no widget state) so it can be — and is — unit tested on +// its own. +// +// Values are keyed by FieldSpec.Key. A missing or empty value emits nothing +// unless the field's EmitMode specifies otherwise. +func BuildArgs(specs []FieldSpec, values map[string]string) []string { + var out []string + for _, s := range specs { + v := values[s.Key] + if s.Transform != nil { + v = s.Transform(strings.TrimSpace(v)) + } + switch s.Emit { + case EmitLongFlag: + if strings.TrimSpace(v) == "" { + continue + } + out = append(out, "--"+s.Key, v) + case EmitLongFlagEq: + if strings.TrimSpace(v) == "" { + continue + } + out = append(out, "--"+s.Key+"="+v) + case EmitRepeatedLongFlagEq: + for _, piece := range strings.Fields(v) { + out = append(out, "--"+s.Key+"="+piece) + } + case EmitPresence: + if isTruthy(v) { + out = append(out, "--"+s.Key) + } + case EmitConstant: + if isTruthy(v) { + out = append(out, s.Constant...) + } + case EmitPositional: + if strings.TrimSpace(v) == "" { + continue + } + out = append(out, v) + case EmitNone: + // no-op + } + } + return out +} + +func isTruthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "on": + return true + } + return false +} diff --git a/pkg/tui/components/form/checkbox.go b/pkg/tui/components/form/checkbox.go new file mode 100644 index 000000000000..10a53134e0d2 --- /dev/null +++ b/pkg/tui/components/form/checkbox.go @@ -0,0 +1,109 @@ +package form + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// CheckboxField is a boolean toggle rendered as `[ ] Label` / `[x] Label`. +// It replaces the legacy "type true/false" text inputs. +type CheckboxField struct { + spec FieldSpec + checked bool + focused bool + err string +} + +// NewCheckboxField constructs a CheckboxField for the given spec. +func NewCheckboxField(spec FieldSpec) *CheckboxField { + c := &CheckboxField{spec: spec} + c.checked = isTruthy(spec.Default) + return c +} + +// Spec returns the underlying FieldSpec. +func (c *CheckboxField) Spec() FieldSpec { return c.spec } + +// Value returns "true" when checked, "" otherwise. +func (c *CheckboxField) Value() string { + if c.checked { + return "true" + } + return "" +} + +// SetValue accepts any truthy string ("true"/"1"/"yes"/"on") as checked. +func (c *CheckboxField) SetValue(v string) { c.checked = isTruthy(v) } + +// Focus marks the checkbox as focused. +func (c *CheckboxField) Focus() tea.Cmd { c.focused = true; return nil } + +// Blur removes focus. +func (c *CheckboxField) Blur() { c.focused = false } + +// Focused reports whether the checkbox has focus. +func (c *CheckboxField) Focused() bool { return c.focused } + +// Update toggles the checkbox on space / x. +func (c *CheckboxField) Update(msg tea.Msg) (Field, tea.Cmd) { + if !c.focused { + return c, nil + } + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case " ", "x", "space": + c.checked = !c.checked + case "y", "Y": + c.checked = true + case "n", "N": + c.checked = false + } + } + return c, nil +} + +// View renders `[ ] Label` / `[x] Label`. +func (c *CheckboxField) View() string { + styles := theme.DefaultStyles() + box := "[ ]" + if c.checked { + box = "[x]" + } + label := c.spec.Label + if label == "" { + label = c.spec.Key + } + line := box + " " + label + if c.focused { + line = styles.Primary.Render("❯ " + line) + } else { + line = " " + line + } + var b strings.Builder + b.WriteString(line) + b.WriteString("\n") + if c.spec.Help != "" { + b.WriteString(" " + styles.Hint.Render(c.spec.Help)) + b.WriteString("\n") + } + b.WriteString("\n") + return b.String() +} + +// Error returns the most recent validation error. +func (c *CheckboxField) Error() string { return c.err } + +// Validate runs validators over the current value. +func (c *CheckboxField) Validate() error { + c.err = "" + for _, v := range c.spec.Validators { + if err := v(c.Value()); err != nil { + c.err = err.Error() + return err + } + } + return nil +} diff --git a/pkg/tui/components/form/container.go b/pkg/tui/components/form/container.go new file mode 100644 index 000000000000..c00166896ba5 --- /dev/null +++ b/pkg/tui/components/form/container.go @@ -0,0 +1,208 @@ +package form + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// SubmitMsg is emitted by Form.Update when the user presses enter on a valid +// form. Parent pages listen for it to advance their flow. +type SubmitMsg struct { + // Values maps FieldSpec.Key to the current value of each field. + Values map[string]string + // Args is the kingpin arg vector produced by Form.Args(). + Args []string +} + +// Form is a container that wires a slice of FieldSpecs into working widgets, +// handles focus / submit, runs validators and constraints, and produces an +// arg vector via Args. +type Form struct { + fields []Field + constraints []Constraint + focused int + topError string + width int + height int + submitKeys key.Binding + nextKeys key.Binding + prevKeys key.Binding +} + +// New constructs a Form from the given field specs and optional cross-field +// constraints. +func New(specs []FieldSpec, constraints ...Constraint) *Form { + f := &Form{ + constraints: constraints, + submitKeys: key.NewBinding(key.WithKeys("ctrl+s")), + nextKeys: key.NewBinding(key.WithKeys("down")), + prevKeys: key.NewBinding(key.WithKeys("up")), + } + f.fields = make([]Field, 0, len(specs)) + for _, s := range specs { + f.fields = append(f.fields, newField(s)) + } + f.focused = -1 + f.focusNext() + return f +} + +// Fields returns the underlying field widgets. The slice shares the form's +// backing array and must not be mutated. +func (f *Form) Fields() []Field { return f.fields } + +// Values returns a map from FieldSpec.Key to current value. +func (f *Form) Values() map[string]string { + out := make(map[string]string, len(f.fields)) + for _, fd := range f.fields { + out[fd.Spec().Key] = fd.Value() + } + return out +} + +// Args returns the kingpin-style arg vector produced from the current values. +// The caller is responsible for prepending any subcommand name. +func (f *Form) Args() []string { + specs := make([]FieldSpec, len(f.fields)) + for i, fd := range f.fields { + specs[i] = fd.Spec() + } + return BuildArgs(specs, f.Values()) +} + +// Summary returns a human-readable recap of non-empty field values. +func (f *Form) Summary() string { + var b strings.Builder + for _, fd := range f.fields { + v := fd.Value() + if strings.TrimSpace(v) == "" { + continue + } + label := fd.Spec().Label + if label == "" { + label = fd.Spec().Key + } + b.WriteString("\t") + b.WriteString(label) + b.WriteString(": ") + b.WriteString(v) + b.WriteString("\n") + } + return b.String() +} + +// Valid runs every validator and every constraint and returns true if none +// produce an error. Side effect: per-field errors are stored on the +// individual widgets, and the form's top-level constraint error (if any) is +// stored too so View() can render it. +func (f *Form) Valid() bool { + ok := true + for _, fd := range f.fields { + if err := fd.Validate(); err != nil { + ok = false + } + } + f.topError = "" + for _, c := range f.constraints { + if err := c(f.Values()); err != nil { + ok = false + if f.topError == "" { + f.topError = err.Error() + } + } + } + return ok +} + +func (f *Form) focusNext() { + if len(f.fields) == 0 { + return + } + if f.focused >= 0 && f.focused < len(f.fields) { + f.fields[f.focused].Blur() + } + f.focused = (f.focused + 1) % len(f.fields) + f.fields[f.focused].Focus() +} + +func (f *Form) focusPrev() { + if len(f.fields) == 0 { + return + } + if f.focused >= 0 && f.focused < len(f.fields) { + f.fields[f.focused].Blur() + } + f.focused = (f.focused - 1 + len(f.fields)) % len(f.fields) + f.fields[f.focused].Focus() +} + +// Update forwards keyboard events to the focused field, handling focus +// cycling and submission at the container level. +func (f *Form) Update(msg tea.Msg) (*Form, tea.Cmd) { + if km, ok := msg.(tea.KeyMsg); ok { + switch { + case key.Matches(km, f.nextKeys): + f.focusNext() + return f, nil + case key.Matches(km, f.prevKeys): + f.focusPrev() + return f, nil + case km.String() == "enter": + if f.focused < len(f.fields)-1 { + f.focusNext() + return f, nil + } + if !f.Valid() { + return f, nil + } + return f, func() tea.Msg { + return SubmitMsg{Values: f.Values(), Args: f.Args()} + } + case key.Matches(km, f.submitKeys): + if !f.Valid() { + return f, nil + } + return f, func() tea.Msg { + return SubmitMsg{Values: f.Values(), Args: f.Args()} + } + } + } + if f.focused >= 0 && f.focused < len(f.fields) { + var cmd tea.Cmd + f.fields[f.focused], cmd = f.fields[f.focused].Update(msg) + return f, cmd + } + return f, nil +} + +// Resize stores the form dimensions and forwards the width to text inputs +// that care about it. +func (f *Form) Resize(w, h int) { + f.width = w + f.height = h + for _, fd := range f.fields { + if sizer, ok := fd.(interface{ SetWidth(int) }); ok { + sizer.SetWidth(w) + } + } +} + +// View renders the form. Each field renders its own label/help/error; the +// form renders the top-level constraint error above all fields. +func (f *Form) View() string { + styles := theme.DefaultStyles() + var b strings.Builder + if f.topError != "" { + b.WriteString(styles.Error.Render("× " + f.topError)) + b.WriteString("\n\n") + } + for _, fd := range f.fields { + b.WriteString(fd.View()) + } + return lipgloss.NewStyle().Render(b.String()) +} diff --git a/pkg/tui/components/form/field.go b/pkg/tui/components/form/field.go new file mode 100644 index 000000000000..b4aa32fa177c --- /dev/null +++ b/pkg/tui/components/form/field.go @@ -0,0 +1,37 @@ +package form + +import tea "github.com/charmbracelet/bubbletea" + +// Field is the common interface every form widget implements. +// +// The form container drives focus and validation through this interface; the +// concrete widget types (TextField, CheckboxField, SelectField) deal with +// rendering and key handling internally. +type Field interface { + Spec() FieldSpec + Value() string + SetValue(string) + Focus() tea.Cmd + Blur() + Focused() bool + Update(tea.Msg) (Field, tea.Cmd) + View() string + Error() string + // Validate runs the spec's Validators in order, stores the first error + // on the field, and returns it. + Validate() error +} + +// newField builds the appropriate widget for a FieldSpec.Kind. +func newField(spec FieldSpec) Field { + switch spec.Kind { + case KindCheckbox: + return NewCheckboxField(spec) + case KindSelect: + return NewSelectField(spec) + case KindSecret: + return NewSecretField(spec) + default: + return NewTextField(spec) + } +} diff --git a/pkg/tui/components/form/form.go b/pkg/tui/components/form/form.go new file mode 100644 index 000000000000..05b8f178d8fe --- /dev/null +++ b/pkg/tui/components/form/form.go @@ -0,0 +1,114 @@ +// Package form is the unified field/validator/widget layer used to collect +// kingpin arguments from the user. +// +// Until phase 2 fills it in with real widgets, this file only declares the +// declarative types sources and pages reach for: +// +// - FieldSpec — the data-only description of a single input. +// - FieldKind — text vs checkbox vs select vs secret. +// - EmitMode — how a field's value is rendered into a kingpin arg vector. +// - Validate — the validator function signature. +// +// The actual TextField / CheckboxField / SelectField widgets and the Form +// container land in phase 2 alongside their unit tests. +package form + +// FieldKind identifies how a field is rendered and how its value is +// interpreted when emitting kingpin arguments. +type FieldKind int + +const ( + // KindText is a free-text input. + KindText FieldKind = iota + // KindCheckbox is a boolean toggle. Replaces typing "true"/"false". + KindCheckbox + // KindSelect is a single-select out of a fixed list of options. + KindSelect + // KindSecret is a free-text input with redacted rendering. + KindSecret +) + +// EmitMode describes how a FieldSpec's value is rendered into the kingpin +// arg vector returned by Form.Args(). +type EmitMode int + +const ( + // EmitLongFlag renders as two tokens: ["--key", value]. + EmitLongFlag EmitMode = iota + // EmitLongFlagEq renders as one token: ["--key=value"]. + EmitLongFlagEq + // EmitPresence renders as ["--key"] when the value is "true" and + // nothing otherwise. Used for boolean checkboxes that map to + // presence-only flags (e.g. --json, --no-verification). + EmitPresence + // EmitConstant renders the field's Constant slice verbatim when the + // value is "true", nothing otherwise. Used for checkboxes that expand + // to a canned flag like --results=verified. + EmitConstant + // EmitPositional renders the value as a single positional argument + // (no flag name). + EmitPositional + // EmitRepeatedLongFlagEq splits the (whitespace-separated) value and + // emits one `--key=v` token per non-empty piece. Used by sources like + // docker (--image=) and s3 (--bucket=) that accept multiple targets as + // a single input. + EmitRepeatedLongFlagEq + // EmitNone is a non-emitting field, e.g. a checkbox that gates another + // field but doesn't itself contribute an argument. + EmitNone +) + +// SelectOption is one entry in a KindSelect field. +type SelectOption struct { + Label string + Value string +} + +// Validate runs on field blur and on form submit. A non-nil error is +// rendered next to the field and blocks submission. +type Validate func(value string) error + +// FieldSpec declaratively describes one input in a Form. Widget behavior and +// the emitted kingpin tokens are both derived from this struct — sources no +// longer hand-build `command []string`. +type FieldSpec struct { + // Key is the kingpin flag name without the leading "--" + // (e.g. "only-verified"). For EmitPositional fields it's used as the + // summary label only. + Key string + // Label is the human-readable label rendered above the input. + Label string + // Help is a secondary description shown under the label. + Help string + // Kind controls the widget type. + Kind FieldKind + // Placeholder is displayed when the field is empty; it is never used + // as a default value (see the plan's "textinputs placeholder hack" + // note). + Placeholder string + // Default is the pre-populated value used when the form is first + // opened. Empty string means no default. + Default string + // Options populates a KindSelect field. + Options []SelectOption + // Validators run in order on blur and on submit. Submission is blocked + // if any returns a non-nil error. + Validators []Validate + // Emit controls how Form.Args() renders this field into tokens. + Emit EmitMode + // Constant is the token slice emitted when Emit is EmitConstant and + // the field value is "true". + Constant []string + // Group tags a field for cross-field constraints (e.g. an XOr group + // shared by several fields that are mutually exclusive). + Group string + // Transform, when non-nil, is applied to the trimmed value before + // emission. Returning "" makes the field emit nothing. Used for + // small, field-local preprocessing (e.g. stripping whitespace inside + // a comma-separated list before emitting --exclude-detectors=...). + Transform func(string) string +} + +// Constraint is a cross-field validator evaluated on submit. Returning a +// non-nil error blocks submission and shows the message above the form. +type Constraint func(values map[string]string) error diff --git a/pkg/tui/components/form/form_test.go b/pkg/tui/components/form/form_test.go new file mode 100644 index 000000000000..47a1440a1948 --- /dev/null +++ b/pkg/tui/components/form/form_test.go @@ -0,0 +1,283 @@ +package form + +import ( + "reflect" + "testing" +) + +func TestBuildArgsEmitModes(t *testing.T) { + t.Parallel() + cases := []struct { + name string + specs []FieldSpec + values map[string]string + want []string + }{ + { + name: "empty spec set emits nothing", + specs: nil, + values: nil, + want: nil, + }, + { + name: "long flag with space-containing value", + specs: []FieldSpec{ + {Key: "path", Emit: EmitLongFlag}, + }, + values: map[string]string{"path": "/tmp/my folder"}, + want: []string{"--path", "/tmp/my folder"}, + }, + { + name: "long flag eq with value", + specs: []FieldSpec{ + {Key: "results", Emit: EmitLongFlagEq}, + }, + values: map[string]string{"results": "verified"}, + want: []string{"--results=verified"}, + }, + { + name: "long flag and long flag eq skip empty values", + specs: []FieldSpec{ + {Key: "a", Emit: EmitLongFlag}, + {Key: "b", Emit: EmitLongFlagEq}, + }, + values: map[string]string{"a": "", "b": " "}, + want: nil, + }, + { + name: "presence only emits when truthy", + specs: []FieldSpec{ + {Key: "json", Emit: EmitPresence}, + {Key: "no-verification", Emit: EmitPresence}, + {Key: "verbose", Emit: EmitPresence}, + }, + values: map[string]string{ + "json": "true", + "no-verification": "false", + "verbose": "", + }, + want: []string{"--json"}, + }, + { + name: "constant expands when truthy", + specs: []FieldSpec{ + {Key: "only-verified", Emit: EmitConstant, Constant: []string{"--results=verified"}}, + }, + values: map[string]string{"only-verified": "true"}, + want: []string{"--results=verified"}, + }, + { + name: "constant emits nothing when falsy", + specs: []FieldSpec{ + {Key: "only-verified", Emit: EmitConstant, Constant: []string{"--results=verified"}}, + }, + values: map[string]string{"only-verified": ""}, + want: nil, + }, + { + name: "positional renders value only", + specs: []FieldSpec{ + {Key: "uri", Emit: EmitPositional}, + }, + values: map[string]string{"uri": "https://example.com/repo.git"}, + want: []string{"https://example.com/repo.git"}, + }, + { + name: "emit none contributes nothing", + specs: []FieldSpec{ + {Key: "gate", Emit: EmitNone}, + {Key: "real", Emit: EmitLongFlag}, + }, + values: map[string]string{"gate": "true", "real": "value"}, + want: []string{"--real", "value"}, + }, + { + name: "ordering follows spec order", + specs: []FieldSpec{ + {Key: "a", Emit: EmitLongFlag}, + {Key: "b", Emit: EmitPresence}, + {Key: "c", Emit: EmitPositional}, + }, + values: map[string]string{"a": "1", "b": "true", "c": "pos"}, + want: []string{"--a", "1", "--b", "pos"}, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := BuildArgs(tc.specs, tc.values) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("BuildArgs(%v) = %v, want %v", tc.values, got, tc.want) + } + }) + } +} + +func TestBuildArgsRepeatedLongFlagEq(t *testing.T) { + t.Parallel() + specs := []FieldSpec{ + {Key: "image", Emit: EmitRepeatedLongFlagEq}, + } + got := BuildArgs(specs, map[string]string{"image": " a b\tc"}) + want := []string{"--image=a", "--image=b", "--image=c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("repeated = %v, want %v", got, want) + } +} + +func TestBuildArgsTransform(t *testing.T) { + t.Parallel() + specs := []FieldSpec{ + { + Key: "exclude-detectors", + Emit: EmitLongFlagEq, + Transform: func(v string) string { + // strip inner whitespace like the legacy code did + out := make([]byte, 0, len(v)) + for i := 0; i < len(v); i++ { + if v[i] != ' ' { + out = append(out, v[i]) + } + } + return string(out) + }, + }, + } + got := BuildArgs(specs, map[string]string{"exclude-detectors": " foo , bar , baz "}) + want := []string{"--exclude-detectors=foo,bar,baz"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("transform = %v, want %v", got, want) + } +} + +func TestRequired(t *testing.T) { + t.Parallel() + r := Required() + if err := r(""); err == nil { + t.Fatal("Required must reject empty") + } + if err := r(" "); err == nil { + t.Fatal("Required must reject whitespace-only") + } + if err := r("ok"); err != nil { + t.Fatalf("Required must accept non-empty: %v", err) + } +} + +func TestInteger(t *testing.T) { + t.Parallel() + v := Integer(1, 10) + if err := v(""); err != nil { + t.Fatalf("Integer must accept empty (pair with Required): %v", err) + } + if err := v("abc"); err == nil { + t.Fatal("Integer must reject non-integer") + } + if err := v("0"); err == nil { + t.Fatal("Integer must reject below min") + } + if err := v("11"); err == nil { + t.Fatal("Integer must reject above max") + } + if err := v("5"); err != nil { + t.Fatalf("Integer must accept in range: %v", err) + } +} + +func TestOneOf(t *testing.T) { + t.Parallel() + v := OneOf("verified", "unverified") + if err := v("verified"); err != nil { + t.Fatalf("OneOf must accept match: %v", err) + } + if err := v("nonsense"); err == nil { + t.Fatal("OneOf must reject non-match") + } + if err := v(""); err != nil { + t.Fatalf("OneOf must accept empty (pair with Required): %v", err) + } +} + +func TestXOrGroupGitHubFixture(t *testing.T) { + t.Parallel() + specs := []FieldSpec{ + {Key: "org", Group: "target"}, + {Key: "repo", Group: "target"}, + {Key: "endpoint"}, // not in group + } + c := XOrGroup("target", 1, 1, specs) + + if err := c(map[string]string{"org": "truffle", "repo": ""}); err != nil { + t.Fatalf("exactly-one satisfied: %v", err) + } + if err := c(map[string]string{"org": "", "repo": "secrets"}); err != nil { + t.Fatalf("exactly-one satisfied: %v", err) + } + if err := c(map[string]string{"org": "", "repo": ""}); err == nil { + t.Fatal("must reject when none set") + } + if err := c(map[string]string{"org": "a", "repo": "b"}); err == nil { + t.Fatal("must reject when both set") + } + if err := c(map[string]string{"org": " ", "repo": ""}); err == nil { + t.Fatal("whitespace must not count as set") + } +} + +func TestFormArgsIntegration(t *testing.T) { + t.Parallel() + specs := []FieldSpec{ + {Key: "repo", Label: "Repo", Kind: KindText, Emit: EmitLongFlag}, + {Key: "json", Label: "JSON", Kind: KindCheckbox, Emit: EmitPresence}, + {Key: "results", Kind: KindSelect, Emit: EmitLongFlagEq, Options: []SelectOption{ + {Label: "All", Value: "all"}, + {Label: "Verified", Value: "verified"}, + }, Default: "verified"}, + } + f := New(specs) + + f.Fields()[0].SetValue("/tmp/my repo") + f.Fields()[1].SetValue("true") + + got := f.Args() + want := []string{"--repo", "/tmp/my repo", "--json", "--results=verified"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Form.Args() = %v, want %v", got, want) + } +} + +func TestFormValidBlocksOnMissingRequired(t *testing.T) { + t.Parallel() + specs := []FieldSpec{ + {Key: "key", Kind: KindText, Validators: []Validate{Required()}}, + } + f := New(specs) + if f.Valid() { + t.Fatal("empty required must not validate") + } + f.Fields()[0].SetValue("ok") + if !f.Valid() { + t.Fatal("non-empty required must validate") + } +} + +func TestFormValidRunsConstraints(t *testing.T) { + t.Parallel() + specs := []FieldSpec{ + {Key: "org", Group: "target"}, + {Key: "repo", Group: "target"}, + } + f := New(specs, XOrGroup("target", 1, 1, specs)) + if f.Valid() { + t.Fatal("neither field set must fail constraint") + } + f.Fields()[0].SetValue("truffle") + if !f.Valid() { + t.Fatal("org set only must pass") + } + f.Fields()[1].SetValue("secrets") + if f.Valid() { + t.Fatal("both set must fail constraint") + } +} diff --git a/pkg/tui/components/form/select.go b/pkg/tui/components/form/select.go new file mode 100644 index 000000000000..1bd5ab1d5afe --- /dev/null +++ b/pkg/tui/components/form/select.go @@ -0,0 +1,134 @@ +package form + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// SelectField is a single-select over FieldSpec.Options. +type SelectField struct { + spec FieldSpec + index int + focused bool + err string +} + +// NewSelectField constructs a SelectField for the given spec. If the spec's +// Default matches one of the option values, that option starts selected. +func NewSelectField(spec FieldSpec) *SelectField { + s := &SelectField{spec: spec} + for i, o := range spec.Options { + if o.Value == spec.Default { + s.index = i + break + } + } + return s +} + +// Spec returns the underlying FieldSpec. +func (s *SelectField) Spec() FieldSpec { return s.spec } + +// Value returns the currently selected option's Value. +func (s *SelectField) Value() string { + if len(s.spec.Options) == 0 { + return "" + } + if s.index < 0 || s.index >= len(s.spec.Options) { + return "" + } + return s.spec.Options[s.index].Value +} + +// SetValue selects the option whose Value matches v; a no-op if none does. +func (s *SelectField) SetValue(v string) { + for i, o := range s.spec.Options { + if o.Value == v { + s.index = i + return + } + } +} + +// Focus marks the field as focused. +func (s *SelectField) Focus() tea.Cmd { s.focused = true; return nil } + +// Blur removes focus. +func (s *SelectField) Blur() { s.focused = false } + +// Focused reports whether the field has focus. +func (s *SelectField) Focused() bool { return s.focused } + +// Update advances / reverses the selection on h/l, left/right. +func (s *SelectField) Update(msg tea.Msg) (Field, tea.Cmd) { + if !s.focused || len(s.spec.Options) == 0 { + return s, nil + } + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "left", "h": + s.index = (s.index - 1 + len(s.spec.Options)) % len(s.spec.Options) + case "right", "l", " ": + s.index = (s.index + 1) % len(s.spec.Options) + } + } + return s, nil +} + +// View renders a horizontal pill group of the options. +func (s *SelectField) View() string { + styles := theme.DefaultStyles() + label := s.spec.Label + if label == "" { + label = s.spec.Key + } + var pills []string + for i, o := range s.spec.Options { + text := " " + o.Label + " " + if i == s.index { + if s.focused { + pills = append(pills, styles.SelectedItem.Render(text)) + } else { + pills = append(pills, styles.Bold.Render("["+o.Label+"]")) + } + } else { + pills = append(pills, styles.Hint.Render(text)) + } + } + var b strings.Builder + b.WriteString(styles.Bold.Render(label)) + b.WriteString("\n") + prefix := " " + if s.focused { + prefix = styles.Primary.Render("❯ ") + } + b.WriteString(prefix + strings.Join(pills, " ")) + b.WriteString("\n") + if s.err != "" { + b.WriteString(styles.Error.Render("× " + s.err)) + b.WriteString("\n") + } else if s.spec.Help != "" { + b.WriteString(styles.Hint.Render(s.spec.Help)) + b.WriteString("\n") + } + b.WriteString("\n") + return b.String() +} + +// Error returns the most recent validation error. +func (s *SelectField) Error() string { return s.err } + +// Validate runs validators over the current value. +func (s *SelectField) Validate() error { + s.err = "" + for _, v := range s.spec.Validators { + if err := v(s.Value()); err != nil { + s.err = err.Error() + return err + } + } + return nil +} diff --git a/pkg/tui/components/form/text.go b/pkg/tui/components/form/text.go new file mode 100644 index 000000000000..4a1c75542912 --- /dev/null +++ b/pkg/tui/components/form/text.go @@ -0,0 +1,119 @@ +package form + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// TextField is a free-text input backed by bubbles/textinput. It adds a +// label, optional help line, and a validation error slot. +type TextField struct { + spec FieldSpec + input textinput.Model + err string + width int +} + +// NewTextField constructs a TextField for the given spec. +func NewTextField(spec FieldSpec) *TextField { + ti := textinput.New() + ti.Placeholder = spec.Placeholder + ti.Prompt = "❯ " + ti.CharLimit = 0 + ti.SetValue(spec.Default) + return &TextField{spec: spec, input: ti} +} + +// NewSecretField is a TextField with redacted echoing. +func NewSecretField(spec FieldSpec) *TextField { + t := NewTextField(spec) + t.input.EchoMode = textinput.EchoPassword + t.input.EchoCharacter = '•' + return t +} + +// Spec returns the underlying FieldSpec. +func (t *TextField) Spec() FieldSpec { return t.spec } + +// Value returns the current input value. +func (t *TextField) Value() string { return t.input.Value() } + +// SetValue overwrites the input value. +func (t *TextField) SetValue(v string) { t.input.SetValue(v) } + +// Focus transfers focus to the input. +func (t *TextField) Focus() tea.Cmd { return t.input.Focus() } + +// Blur removes focus from the input and runs validators. +func (t *TextField) Blur() { + t.input.Blur() + _ = t.Validate() +} + +// Focused reports whether the input currently has focus. +func (t *TextField) Focused() bool { return t.input.Focused() } + +// Update delegates to the underlying textinput. +func (t *TextField) Update(msg tea.Msg) (Field, tea.Cmd) { + var cmd tea.Cmd + t.input, cmd = t.input.Update(msg) + return t, cmd +} + +// View renders the label, input, help, and any validation error. +func (t *TextField) View() string { + styles := theme.DefaultStyles() + var b strings.Builder + label := t.spec.Label + if label == "" { + label = t.spec.Key + } + b.WriteString(styles.Bold.Render(label)) + b.WriteString("\n") + b.WriteString(t.input.View()) + b.WriteString("\n") + if t.err != "" { + b.WriteString(styles.Error.Render("× " + t.err)) + b.WriteString("\n") + } else if t.spec.Help != "" { + b.WriteString(styles.Hint.Render(t.spec.Help)) + b.WriteString("\n") + } + b.WriteString("\n") + return b.String() +} + +// Error returns the most recent validation error message. +func (t *TextField) Error() string { return t.err } + +// Validate runs the spec's Validators against the current value. +func (t *TextField) Validate() error { + t.err = "" + for _, v := range t.spec.Validators { + if err := v(t.input.Value()); err != nil { + t.err = err.Error() + return err + } + } + return nil +} + +// SetWidth hints the input width. bubbles/textinput pads the value with +// trailing spaces up to Width, so we cap it to avoid painting hundreds of +// columns of padding on wide terminals while still leaving headroom under +// the content area on narrower ones. +func (t *TextField) SetWidth(w int) { + t.width = w + width := w - 6 + if width > 80 { + width = 80 + } + if width < 10 { + width = 10 + } + t.input.Width = width +} diff --git a/pkg/tui/components/form/validate.go b/pkg/tui/components/form/validate.go new file mode 100644 index 000000000000..af21e215e18f --- /dev/null +++ b/pkg/tui/components/form/validate.go @@ -0,0 +1,90 @@ +package form + +import ( + "fmt" + "strconv" + "strings" +) + +// Required returns a Validate that rejects the empty string (after trim). +// +// Unlike the legacy textinputs.Required flag, this never silently substitutes +// a placeholder — the form simply refuses to submit until the user enters a +// value. +func Required() Validate { + return func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("required") + } + return nil + } +} + +// Integer returns a Validate that accepts base-10 integers in [min, max]. +// Use math.MinInt / math.MaxInt for an unbounded side. +func Integer(min, max int) Validate { + return func(value string) error { + v := strings.TrimSpace(value) + if v == "" { + return nil + } + n, err := strconv.Atoi(v) + if err != nil { + return fmt.Errorf("must be an integer") + } + if n < min || n > max { + return fmt.Errorf("must be between %d and %d", min, max) + } + return nil + } +} + +// OneOf returns a Validate that accepts exactly one of the supplied strings +// (case-sensitive). Used by SelectField to reject values that aren't part of +// the option set, and also useful as a free-text "enum" check. +func OneOf(options ...string) Validate { + return func(value string) error { + if value == "" { + return nil + } + for _, o := range options { + if value == o { + return nil + } + } + return fmt.Errorf("must be one of: %s", strings.Join(options, ", ")) + } +} + +// XOrGroup returns a Constraint that requires between min and max fields in +// the named Group to have a non-empty value. Typical use: exactly one of +// {--org, --repo} for GitHub (min=1, max=1). +// +// The returned Constraint relies on FieldSpec.Group matching group. +func XOrGroup(group string, min, max int, specs []FieldSpec) Constraint { + keys := make([]string, 0) + for _, s := range specs { + if s.Group == group { + keys = append(keys, s.Key) + } + } + return func(values map[string]string) error { + set := 0 + for _, k := range keys { + if strings.TrimSpace(values[k]) != "" { + set++ + } + } + switch { + case set < min && min == max: + return fmt.Errorf("exactly %d of (%s) must be set", min, strings.Join(keys, ", ")) + case set < min: + return fmt.Errorf("at least %d of (%s) must be set", min, strings.Join(keys, ", ")) + case set > max && min == max: + return fmt.Errorf("exactly %d of (%s) may be set", max, strings.Join(keys, ", ")) + case set > max: + return fmt.Errorf("at most %d of (%s) may be set", max, strings.Join(keys, ", ")) + } + return nil + } +} diff --git a/pkg/tui/components/formfield/formfield.go b/pkg/tui/components/formfield/formfield.go deleted file mode 100644 index ab00749fcd80..000000000000 --- a/pkg/tui/components/formfield/formfield.go +++ /dev/null @@ -1,38 +0,0 @@ -package formfield - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -type FormField struct { - Label string - Required bool - Help string - Component tea.Model -} - -func NewFormField(common common.Common) *FormField { - return &FormField{} -} - -func (field *FormField) ViewLabel() string { - var label strings.Builder - if field.Required { - label.WriteString(styles.BoldTextStyle.Render(field.Label) + "*\n") - } else { - label.WriteString(styles.BoldTextStyle.Render(field.Label) + "\n") - } - - return label.String() -} - -func (field *FormField) ViewHelp() string { - var help strings.Builder - help.WriteString(styles.HintTextStyle.Render(field.Help) + "\n") - - return help.String() -} diff --git a/pkg/tui/components/header/header.go b/pkg/tui/components/header/header.go deleted file mode 100644 index f83c191ad3a7..000000000000 --- a/pkg/tui/components/header/header.go +++ /dev/null @@ -1,42 +0,0 @@ -package header - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" -) - -// Header represents a header component. -type Header struct { - common common.Common - text string -} - -// New creates a new header component. -func New(c common.Common, text string) *Header { - return &Header{ - common: c, - text: text, - } -} - -// SetSize implements common.Component. -func (h *Header) SetSize(width, height int) { - h.common.SetSize(width, height) -} - -// Init implements tea.Model. -func (h *Header) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (h *Header) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return h, nil -} - -// View implements tea.Model. -func (h *Header) View() string { - return h.common.Styles.ServerName.Render(strings.TrimSpace(h.text)) -} diff --git a/pkg/tui/components/selector/selector.go b/pkg/tui/components/selector/selector.go deleted file mode 100644 index 3b1b2ebb08e0..000000000000 --- a/pkg/tui/components/selector/selector.go +++ /dev/null @@ -1,216 +0,0 @@ -package selector - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" -) - -// Selector is a list of items that can be selected. -type Selector struct { - list.Model - common common.Common - active int - filterState list.FilterState -} - -// IdentifiableItem is an item that can be identified by a string. Implements -// list.DefaultItem. -type IdentifiableItem interface { - list.DefaultItem - ID() string -} - -// ItemDelegate is a wrapper around list.ItemDelegate. -type ItemDelegate interface { - list.ItemDelegate -} - -// SelectMsg is a message that is sent when an item is selected. -type SelectMsg struct{ IdentifiableItem } - -// ActiveMsg is a message that is sent when an item is active but not selected. -type ActiveMsg struct{ IdentifiableItem } - -// New creates a new selector. -func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector { - itms := make([]list.Item, len(items)) - for i, item := range items { - itms[i] = item - } - l := list.New(itms, delegate, common.Width, common.Height) - s := &Selector{ - Model: l, - common: common, - } - s.SetSize(common.Width, common.Height) - return s -} - -// PerPage returns the number of items per page. -func (s *Selector) PerPage() int { - return s.Model.Paginator.PerPage -} - -// SetPage sets the current page. -func (s *Selector) SetPage(page int) { - s.Model.Paginator.Page = page -} - -// Page returns the current page. -func (s *Selector) Page() int { - return s.Model.Paginator.Page -} - -// TotalPages returns the total number of pages. -func (s *Selector) TotalPages() int { - return s.Model.Paginator.TotalPages -} - -// Select selects the item at the given index. -func (s *Selector) Select(index int) { - s.Model.Select(index) -} - -// SetShowTitle sets the show title flag. -func (s *Selector) SetShowTitle(show bool) { - s.Model.SetShowTitle(show) -} - -// SetShowHelp sets the show help flag. -func (s *Selector) SetShowHelp(show bool) { - s.Model.SetShowHelp(show) -} - -// SetShowStatusBar sets the show status bar flag. -func (s *Selector) SetShowStatusBar(show bool) { - s.Model.SetShowStatusBar(show) -} - -// DisableQuitKeybindings disables the quit keybindings. -func (s *Selector) DisableQuitKeybindings() { - s.Model.DisableQuitKeybindings() -} - -// SetShowFilter sets the show filter flag. -func (s *Selector) SetShowFilter(show bool) { - s.Model.SetShowFilter(show) -} - -// SetShowPagination sets the show pagination flag. -func (s *Selector) SetShowPagination(show bool) { - s.Model.SetShowPagination(show) -} - -// SetFilteringEnabled sets the filtering enabled flag. -func (s *Selector) SetFilteringEnabled(enabled bool) { - s.Model.SetFilteringEnabled(enabled) -} - -// SetSize implements common.Component. -func (s *Selector) SetSize(width, height int) { - s.common.SetSize(width, height) - s.Model.SetSize(width, height) -} - -// SetItems sets the items in the selector. -func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd { - its := make([]list.Item, len(items)) - for i, item := range items { - its[i] = item - } - return s.Model.SetItems(its) -} - -// Index returns the index of the selected item. -func (s *Selector) Index() int { - return s.Model.Index() -} - -// Init implements tea.Model. -func (s *Selector) Init() tea.Cmd { - return s.activeCmd -} - -// Update implements tea.Model. -func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.KeyMsg: - filterState := s.Model.FilterState() - switch { - case key.Matches(msg, s.common.KeyMap.Help): - if filterState == list.Filtering { - return s, tea.Batch(cmds...) - } - case key.Matches(msg, s.common.KeyMap.Select): - if filterState != list.Filtering { - cmds = append(cmds, s.selectCmd) - } - } - case list.FilterMatchesMsg: - cmds = append(cmds, s.activeFilterCmd) - } - m, cmd := s.Model.Update(msg) - s.Model = m - if cmd != nil { - cmds = append(cmds, cmd) - } - // Track filter state and update active item when filter state changes. - filterState := s.Model.FilterState() - if s.filterState != filterState { - cmds = append(cmds, s.activeFilterCmd) - } - s.filterState = filterState - // Send ActiveMsg when index change. - if s.active != s.Model.Index() { - cmds = append(cmds, s.activeCmd) - } - s.active = s.Model.Index() - return s, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (s *Selector) View() string { - return s.Model.View() -} - -// SelectItem is a command that selects the currently active item. -func (s *Selector) SelectItem() tea.Msg { - return s.selectCmd() -} - -func (s *Selector) selectCmd() tea.Msg { - item := s.Model.SelectedItem() - i, ok := item.(IdentifiableItem) - if !ok { - return SelectMsg{} - } - return SelectMsg{i} -} - -func (s *Selector) activeCmd() tea.Msg { - item := s.Model.SelectedItem() - i, ok := item.(IdentifiableItem) - if !ok { - return ActiveMsg{} - } - return ActiveMsg{i} -} - -func (s *Selector) activeFilterCmd() tea.Msg { - // Here we use VisibleItems because when list.FilterMatchesMsg is sent, - // VisibleItems is the only way to get the list of filtered items. The list - // bubble should export something like list.FilterMatchesMsg.Items(). - items := s.Model.VisibleItems() - if len(items) == 0 { - return nil - } - item := items[0] - i, ok := item.(IdentifiableItem) - if !ok { - return nil - } - return ActiveMsg{i} -} diff --git a/pkg/tui/components/statusbar/statusbar.go b/pkg/tui/components/statusbar/statusbar.go deleted file mode 100644 index a486784bf98f..000000000000 --- a/pkg/tui/components/statusbar/statusbar.go +++ /dev/null @@ -1,88 +0,0 @@ -package statusbar - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/truncate" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" -) - -// StatusBarMsg is a message sent to the status bar. -type StatusBarMsg struct { - Key string - Value string - Info string - Branch string -} - -// StatusBar is a status bar model. -type StatusBar struct { - common common.Common - msg StatusBarMsg -} - -// Model is an interface that supports setting the status bar information. -type Model interface { - StatusBarValue() string - StatusBarInfo() string -} - -// New creates a new status bar component. -func New(c common.Common) *StatusBar { - s := &StatusBar{ - common: c, - } - return s -} - -// SetSize implements common.Component. -func (s *StatusBar) SetSize(width, height int) { - s.common.Width = width - s.common.Height = height -} - -// Init implements tea.Model. -func (s *StatusBar) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case StatusBarMsg: - s.msg = msg - } - return s, nil -} - -// View implements tea.Model. -func (s *StatusBar) View() string { - st := s.common.Styles - w := lipgloss.Width - help := s.common.Zone.Mark( - "repo-help", - st.StatusBarHelp.Render("? Help"), - ) - key := st.StatusBarKey.Render(s.msg.Key) - info := "" - if s.msg.Info != "" { - info = st.StatusBarInfo.Render(s.msg.Info) - } - branch := st.StatusBarBranch.Render(s.msg.Branch) - maxWidth := s.common.Width - w(key) - w(info) - w(branch) - w(help) - v := truncate.StringWithTail(s.msg.Value, uint(maxWidth-st.StatusBarValue.GetHorizontalFrameSize()), "…") - value := st.StatusBarValue. - Width(maxWidth). - Render(v) - - return lipgloss.NewStyle().MaxWidth(s.common.Width). - Render( - lipgloss.JoinHorizontal(lipgloss.Top, - key, - value, - info, - branch, - help, - ), - ) -} diff --git a/pkg/tui/components/tabs/tabs.go b/pkg/tui/components/tabs/tabs.go deleted file mode 100644 index 521bf345d6d0..000000000000 --- a/pkg/tui/components/tabs/tabs.go +++ /dev/null @@ -1,113 +0,0 @@ -package tabs - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" -) - -// SelectTabMsg is a message that contains the index of the tab to select. -type SelectTabMsg int - -// ActiveTabMsg is a message that contains the index of the current active tab. -type ActiveTabMsg int - -// Tabs is bubbletea component that displays a list of tabs. -type Tabs struct { - common common.Common - tabs []string - activeTab int - TabSeparator lipgloss.Style - TabInactive lipgloss.Style - TabActive lipgloss.Style - TabDot lipgloss.Style - UseDot bool -} - -// New creates a new Tabs component. -func New(c common.Common, tabs []string) *Tabs { - r := &Tabs{ - common: c, - tabs: tabs, - activeTab: 0, - TabSeparator: c.Styles.TabSeparator, - TabInactive: c.Styles.TabInactive, - TabActive: c.Styles.TabActive, - } - return r -} - -// SetSize implements common.Component. -func (t *Tabs) SetSize(width, height int) { - t.common.SetSize(width, height) -} - -// Init implements tea.Model. -func (t *Tabs) Init() tea.Cmd { - t.activeTab = 0 - return nil -} - -// Update implements tea.Model. -func (t *Tabs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "tab": - t.activeTab = (t.activeTab + 1) % len(t.tabs) - cmds = append(cmds, t.activeTabCmd) - case "shift+tab": - t.activeTab = (t.activeTab - 1 + len(t.tabs)) % len(t.tabs) - cmds = append(cmds, t.activeTabCmd) - } - case SelectTabMsg: - tab := int(msg) - if tab >= 0 && tab < len(t.tabs) { - t.activeTab = int(msg) - } - } - return t, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (t *Tabs) View() string { - s := strings.Builder{} - sep := t.TabSeparator - for i, tab := range t.tabs { - style := t.TabInactive - prefix := " " - if i == t.activeTab { - style = t.TabActive - prefix = t.TabDot.Render("• ") - } - if t.UseDot { - s.WriteString(prefix) - } - s.WriteString( - t.common.Zone.Mark( - tab, - style.Render(tab), - ), - ) - if i != len(t.tabs)-1 { - s.WriteString(sep.String()) - } - } - return lipgloss.NewStyle(). - MaxWidth(t.common.Width). - Render(s.String()) -} - -func (t *Tabs) activeTabCmd() tea.Msg { - return ActiveTabMsg(t.activeTab) -} - -// SelectTabCmd is a bubbletea command that selects the tab at the given index. -func SelectTabCmd(tab int) tea.Cmd { - return func() tea.Msg { - return SelectTabMsg(tab) - } -} diff --git a/pkg/tui/components/textinput/textinput.go b/pkg/tui/components/textinput/textinput.go deleted file mode 100644 index 14a1675cac08..000000000000 --- a/pkg/tui/components/textinput/textinput.go +++ /dev/null @@ -1,51 +0,0 @@ -package textinput - -import ( - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -type ( - errMsg error -) - -type TextInput struct { - textInput textinput.Model - err error -} - -func New(placeholder string) TextInput { - ti := textinput.New() - ti.Placeholder = placeholder - ti.Focus() - ti.CharLimit = 156 - ti.Width = 60 - - return TextInput{ - textInput: ti, - err: nil, - } -} - -func (m TextInput) Init() tea.Cmd { - return textinput.Blink -} - -func (m TextInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - - // We handle errors just like any other message - case errMsg: - m.err = msg - return m, nil - } - - m.textInput, cmd = m.textInput.Update(msg) - return m, cmd -} - -func (m TextInput) View() string { - return m.textInput.View() -} diff --git a/pkg/tui/components/textinputs/textinputs.go b/pkg/tui/components/textinputs/textinputs.go deleted file mode 100644 index 5f5a41804155..000000000000 --- a/pkg/tui/components/textinputs/textinputs.go +++ /dev/null @@ -1,276 +0,0 @@ -package textinputs - -// from https://github.com/charmbracelet/bubbletea/blob/master/examples/textinputs/main.go - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var ( - focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - noStyle = lipgloss.NewStyle() - helpStyle = blurredStyle - // cursorStyle = focusedStyle. - // cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) - - focusedSkipButton = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("[ Run with defaults ]") - blurredSkipButton = fmt.Sprintf("[ %s ]", lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Run with defaults")) -) - -// SelectNextMsg used for emitting events when the 'Next' button is selected. -type SelectNextMsg int - -// SelectSkipMsg used for emitting events when the 'Skip' button is selected. -type SelectSkipMsg int - -type Model struct { - focusIndex int - inputs []textinput.Model - configs []InputConfig - // cursorMode cursor.Mode - skipButton bool - submitMsg string - header string - footer string -} - -type InputConfig struct { - Label string - Key string - Help string - Required bool - Placeholder string - RedactInput bool -} - -type Input struct { - Value string - IsDefault bool -} - -func (m Model) GetInputs() map[string]Input { - inputs := make(map[string]Input) - - for i, input := range m.inputs { - isDefault := false - value := input.Value() - if value == "" && m.configs[i].Required { - isDefault = true - value = input.Placeholder - } - inputs[m.configs[i].Key] = Input{Value: value, IsDefault: isDefault} - } - - return inputs -} - -func (m Model) GetLabels() map[string]string { - labels := make(map[string]string) - - for _, config := range m.configs { - labels[config.Key] = config.Label - } - - return labels -} - -func New(config []InputConfig) Model { - m := Model{ - inputs: make([]textinput.Model, len(config)), - submitMsg: "Next", - } - - for i, conf := range config { - input := textinput.New() - input.Placeholder = conf.Placeholder - - if i == 0 { - input.Focus() - input.TextStyle = focusedStyle - input.PromptStyle = focusedStyle - } - - m.inputs[i] = input - } - - m.configs = config - return m -} - -func (m Model) Init() tea.Cmd { - return textinput.Blink -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - // Set focus to next input - case "enter", "up", "down": - s := msg.String() - - // Did the user press enter while the submit or skip button was focused? - // If so, emit the appropriate command. - if s == "enter" && m.focusIndex == len(m.inputs) { - return m, func() tea.Msg { return SelectNextMsg(0) } - } else if s == "enter" && m.focusIndex == -1 { - return m, func() tea.Msg { return SelectSkipMsg(0) } - } - - // Cycle indexes - if s == "up" { - m.focusIndex-- - } else { - m.focusIndex++ - } - - if m.focusIndex > len(m.inputs) { - m.focusIndex = 0 - } else if !m.skipButton && m.focusIndex < 0 { - m.focusIndex = len(m.inputs) - } else if m.skipButton && m.focusIndex < -1 { - m.focusIndex = len(m.inputs) - } - - cmds := make([]tea.Cmd, len(m.inputs)) - for i := 0; i < len(m.inputs); i++ { - if i == m.focusIndex { - // Set focused state - cmds[i] = m.focusInput(i) - continue - } - // Remove focused state - m.unfocusInput(i) - } - - return m, tea.Batch(cmds...) - } - } - - // Handle character input and blinking - cmd := m.updateInputs(msg) - - return m, cmd -} - -func (m *Model) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.inputs)) - - // Only text inputs with Focus() set will respond, so it's safe to simply - // update all of them here without any further logic. - for i := range m.inputs { - m.inputs[i], cmds[i] = m.inputs[i].Update(msg) - } - - return tea.Batch(cmds...) -} - -func (m Model) View() string { - var b strings.Builder - - if m.header != "" { - fmt.Fprintf(&b, "%s\n\n", m.header) - } - - if m.skipButton { - button := &blurredSkipButton - if m.focusIndex == -1 { - button = &focusedSkipButton - } - fmt.Fprintf(&b, "%s\n\n\n", *button) - } - - for i := range m.inputs { - if m.configs[i].Label != "" { - b.WriteString(m.GetLabel(m.configs[i])) - } - - input := m.inputs[i] - if val := input.Value(); len(val) > 4 && m.configs[i].RedactInput { - if len(val) > 10 { - // start***end - input.SetValue(val[:4] + strings.Repeat("*", len(val)-8) + val[len(val)-4:]) - } else { - // start*** - input.SetValue(val[:4] + strings.Repeat("*", len(val)-4)) - } - } - b.WriteString(input.View()) - b.WriteRune('\n') - if i < len(m.inputs)-1 { - b.WriteRune('\n') - } - } - - if m.footer != "" { - fmt.Fprintf(&b, "\n\n%s", m.footer) - } - - button := blurredStyle.Render(m.submitMsg) - if m.focusIndex == len(m.inputs) { - button = focusedStyle.Render(fmt.Sprintf("[ %s ]", m.submitMsg)) - } - fmt.Fprintf(&b, "\n\n%s\n\n", button) - - return b.String() -} - -func (m Model) GetLabel(c InputConfig) string { - var label strings.Builder - - label.WriteString(c.Label) - if c.Required { - label.WriteString("*") - } - - if len(c.Help) > 0 { - label.WriteString("\n" + helpStyle.Render(c.Help)) - } - - label.WriteString("\n") - return label.String() -} - -func (m Model) SetSkip(skip bool) Model { - m.skipButton = skip - if m.skipButton { - if len(m.inputs) > 0 { - m.unfocusInput(0) - } - m.focusIndex = -1 - } - return m -} - -func (m Model) SetSubmitMsg(msg string) Model { - m.submitMsg = msg - return m -} - -func (m Model) SetFooter(foot string) Model { - m.footer = foot - return m -} - -func (m Model) SetHeader(head string) Model { - m.header = head - return m -} - -func (m *Model) unfocusInput(index int) { - m.inputs[index].Blur() - m.inputs[index].PromptStyle = noStyle - m.inputs[index].TextStyle = noStyle -} - -func (m *Model) focusInput(index int) tea.Cmd { - m.inputs[index].PromptStyle = focusedStyle - m.inputs[index].TextStyle = focusedStyle - return m.inputs[index].Focus() -} diff --git a/pkg/tui/components/viewport/viewport.go b/pkg/tui/components/viewport/viewport.go deleted file mode 100644 index 77fa618285b7..000000000000 --- a/pkg/tui/components/viewport/viewport.go +++ /dev/null @@ -1,97 +0,0 @@ -package viewport - -import ( - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" -) - -// Viewport represents a viewport component. -type Viewport struct { - common common.Common - *viewport.Model -} - -// New returns a new Viewport. -func New(c common.Common) *Viewport { - vp := viewport.New(c.Width, c.Height) - vp.MouseWheelEnabled = true - return &Viewport{ - common: c, - Model: &vp, - } -} - -// SetSize implements common.Component. -func (v *Viewport) SetSize(width, height int) { - v.common.SetSize(width, height) - v.Model.Width = width - v.Model.Height = height -} - -// Init implements tea.Model. -func (v *Viewport) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (v *Viewport) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - vp, cmd := v.Model.Update(msg) - v.Model = &vp - return v, cmd -} - -// View implements tea.Model. -func (v *Viewport) View() string { - return v.Model.View() -} - -// SetContent sets the viewport's content. -func (v *Viewport) SetContent(content string) { - v.Model.SetContent(content) -} - -// GotoTop moves the viewport to the top of the log. -func (v *Viewport) GotoTop() { - v.Model.GotoTop() -} - -// GotoBottom moves the viewport to the bottom of the log. -func (v *Viewport) GotoBottom() { - v.Model.GotoBottom() -} - -// HalfViewDown moves the viewport down by half the viewport height. -func (v *Viewport) HalfViewDown() { - v.Model.HalfViewDown() -} - -// HalfViewUp moves the viewport up by half the viewport height. -func (v *Viewport) HalfViewUp() { - v.Model.HalfViewUp() -} - -// ViewUp moves the viewport up by a page. -func (v *Viewport) ViewUp() []string { - return v.Model.ViewUp() -} - -// ViewDown moves the viewport down by a page. -func (v *Viewport) ViewDown() []string { - return v.Model.ViewDown() -} - -// LineUp moves the viewport up by the given number of lines. -func (v *Viewport) LineUp(n int) []string { - return v.Model.LineUp(n) -} - -// LineDown moves the viewport down by the given number of lines. -func (v *Viewport) LineDown(n int) []string { - return v.Model.LineDown(n) -} - -// ScrollPercent returns the viewport's scroll percentage. -func (v *Viewport) ScrollPercent() float64 { - return v.Model.ScrollPercent() -} diff --git a/pkg/tui/keymap/keymap.go b/pkg/tui/keymap/keymap.go deleted file mode 100644 index 6ef71357543f..000000000000 --- a/pkg/tui/keymap/keymap.go +++ /dev/null @@ -1,241 +0,0 @@ -package keymap - -import "github.com/charmbracelet/bubbles/key" - -// KeyMap is a map of key bindings for the UI. -type KeyMap struct { - Quit key.Binding - CmdQuit key.Binding - Left key.Binding - Right key.Binding - Up key.Binding - Down key.Binding - UpDown key.Binding - LeftRight key.Binding - Arrows key.Binding - Select key.Binding - Section key.Binding - Back key.Binding - PrevPage key.Binding - NextPage key.Binding - Help key.Binding - - SelectItem key.Binding - BackItem key.Binding - - Copy key.Binding -} - -// DefaultKeyMap returns the default key map. -func DefaultKeyMap() *KeyMap { - km := new(KeyMap) - - km.Quit = key.NewBinding( - key.WithKeys( - "ctrl+c", - ), - key.WithHelp( - "ctrl+c", - "quit", - ), - ) - - km.CmdQuit = key.NewBinding( - key.WithKeys( - "q", - "ctrl+c", - ), - key.WithHelp( - "q", - "quit", - ), - ) - - km.Up = key.NewBinding( - key.WithKeys( - "up", - "k", - ), - key.WithHelp( - "↑/k", - "up", - ), - ) - - km.Down = key.NewBinding( - key.WithKeys( - "down", - "j", - ), - key.WithHelp( - "↓/j", - "down", - ), - ) - - km.UpDown = key.NewBinding( - key.WithKeys( - "up", - "down", - "k", - "j", - ), - key.WithHelp( - "↑↓", - "navigate", - ), - ) - - km.Left = key.NewBinding( - key.WithKeys( - "left", - "h", - ), - key.WithHelp( - "←/h", - "left", - ), - ) - - km.Right = key.NewBinding( - key.WithKeys( - "right", - "l", - ), - key.WithHelp( - "→/l", - "right", - ), - ) - - km.LeftRight = key.NewBinding( - key.WithKeys( - "left", - "h", - "right", - "l", - ), - key.WithHelp( - "←→", - "navigate", - ), - ) - - km.Arrows = key.NewBinding( - key.WithKeys( - "up", - "right", - "down", - "left", - "k", - "j", - "h", - "l", - ), - key.WithHelp( - "↑←↓→", - "navigate", - ), - ) - - km.Select = key.NewBinding( - key.WithKeys( - "enter", - ), - key.WithHelp( - "enter", - "select", - ), - ) - - km.Section = key.NewBinding( - key.WithKeys( - "tab", - "shift+tab", - ), - key.WithHelp( - "tab", - "section", - ), - ) - - km.Back = key.NewBinding( - key.WithKeys( - "esc", - ), - key.WithHelp( - "esc", - "back", - ), - ) - - km.PrevPage = key.NewBinding( - key.WithKeys( - "pgup", - "b", - "u", - ), - key.WithHelp( - "pgup", - "prev page", - ), - ) - - km.NextPage = key.NewBinding( - key.WithKeys( - "pgdown", - "f", - "d", - ), - key.WithHelp( - "pgdn", - "next page", - ), - ) - - km.Help = key.NewBinding( - key.WithKeys( - "?", - ), - key.WithHelp( - "?", - "toggle help", - ), - ) - - km.SelectItem = key.NewBinding( - key.WithKeys( - "l", - "right", - ), - key.WithHelp( - "→", - "select", - ), - ) - - km.BackItem = key.NewBinding( - key.WithKeys( - "h", - "left", - "backspace", - ), - key.WithHelp( - "←", - "back", - ), - ) - - km.Copy = key.NewBinding( - key.WithKeys( - "c", - "ctrl+c", - ), - key.WithHelp( - "c", - "copy text", - ), - ) - - return km -} diff --git a/pkg/tui/pages/analyze_form/analyze_form.go b/pkg/tui/pages/analyze_form/analyze_form.go deleted file mode 100644 index 6448a3f974b7..000000000000 --- a/pkg/tui/pages/analyze_form/analyze_form.go +++ /dev/null @@ -1,233 +0,0 @@ -package analyze_form - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer" - "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -var ( - titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFDF5")). - Background(lipgloss.Color(styles.Colors["bronze"])). - Padding(0, 1) -) - -type AnalyzeForm struct { - common.Common - KeyType string - form textinputs.Model -} - -type Submission struct { - AnalyzerType string - AnalyzerInfo analyzer.SecretInfo -} - -func New(c common.Common, keyType string) *AnalyzeForm { - var inputs []textinputs.InputConfig - switch strings.ToLower(keyType) { - case "twilio": - inputs = []textinputs.InputConfig{{ - Label: "SID", - Key: "sid", - Required: true, - }, { - Label: "Token", - Key: "key", - Required: true, - RedactInput: true, - }} - case "shopify": - inputs = []textinputs.InputConfig{{ - Label: "Secret", - Key: "key", - Required: true, - RedactInput: true, - }, { - Label: "Shopify URL", - Key: "url", - Required: true, - }} - case "dockerhub": - inputs = []textinputs.InputConfig{{ - Label: "Username", - Key: "username", - Required: true, - }, { - Label: "Token(PAT)", - Key: "pat", - Required: true, - RedactInput: true, - }} - case "planetscale": - inputs = []textinputs.InputConfig{{ - Label: "Service Id", - Key: "id", - Required: true, - }, { - Label: "Service Token", - Key: "token", - Required: true, - RedactInput: true, - }} - case "plaid": - inputs = []textinputs.InputConfig{{ - Label: "Secret", - Key: "secret", - Required: true, - RedactInput: true, - }, { - Label: "Client ID", - Key: "id", - Required: true, - }, { - Label: "Access Token", - Key: "token", - Required: true, - RedactInput: true, - }} - case "datadog": - inputs = []textinputs.InputConfig{{ - Label: "API Key", - Key: "api_key", - Required: true, - RedactInput: true, - }, { - Label: "Application Key", - Key: "app_key", - Required: false, - RedactInput: true, - }, { - Label: "Endpoint (press Enter to skip if unknown; TruffleHog will attempt to auto-detect)", - Key: "endpoint", - Required: false, - }} - case "mux": - inputs = []textinputs.InputConfig{{ - Label: "Secret", - Key: "secret", - Required: true, - RedactInput: true, - }, { - Label: "Key", - Key: "key", - Required: true, - }} - case "databricks": - inputs = []textinputs.InputConfig{{ - Label: "Access Token", - Key: "token", - Required: true, - RedactInput: true, - }, { - Label: "Domain", - Key: "domain", - Required: true, - }} - case "jira": - inputs = []textinputs.InputConfig{{ - Label: "Domain", - Key: "domain", - Required: true, - }, { - Label: "Email", - Key: "email", - Required: true, - }, { - Label: "Token", - Key: "token", - Required: true, - RedactInput: true, - }} - default: - inputs = []textinputs.InputConfig{{ - Label: "Secret", - Key: "key", - Required: true, - RedactInput: true, - }} - } - - // Always append a log file option. - inputs = append(inputs, textinputs.InputConfig{ - Label: "Log file", - Help: "Log HTTP requests that analysis performs to this file", - Key: "log_file", - }) - - form := textinputs.New(inputs). - SetHeader(titleStyle.Render(fmt.Sprintf("Configuring %s analyzer", keyType))). - SetFooter("⚠️ Running TruffleHog Analyze will send a lot of requests ⚠️\n\n🚧 Please confirm you have permission to run TruffleHog Analyze against this secret 🚧"). - SetSubmitMsg("Run TruffleHog Analyze") - return &AnalyzeForm{ - Common: c, - KeyType: keyType, - form: form, - } -} - -func (AnalyzeForm) Init() tea.Cmd { - return nil -} - -type SetAnalyzerMsg string - -func (ui *AnalyzeForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case SetAnalyzerMsg: - ui = New(ui.Common, string(msg)) - return ui, nil - case tea.KeyMsg: - switch { - case key.Matches(msg, ui.Common.KeyMap.Back): - return nil, tea.Quit - } - } - - if _, ok := msg.(textinputs.SelectNextMsg); ok { - values := make(map[string]string) - for k, v := range ui.form.GetInputs() { - values[k] = v.Value - } - secretInfoCmd := func() tea.Msg { - // TODO: Set Config - logFile := values["log_file"] - cfg := config.Config{ - LogFile: logFile, - LoggingEnabled: logFile != "", - } - return Submission{ - AnalyzerType: ui.KeyType, - AnalyzerInfo: analyzer.SecretInfo{Cfg: &cfg, Parts: values}, - } - } - return ui, secretInfoCmd - } - - form, cmd := ui.form.Update(msg) - ui.form = form.(textinputs.Model) - return ui, cmd -} - -func (ui *AnalyzeForm) View() string { - return styles.AppStyle.Render(ui.form.View()) -} - -func (m *AnalyzeForm) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m *AnalyzeForm) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/pages/analyze_keys/analyze_keys.go b/pkg/tui/pages/analyze_keys/analyze_keys.go deleted file mode 100644 index d85bc6e85602..000000000000 --- a/pkg/tui/pages/analyze_keys/analyze_keys.go +++ /dev/null @@ -1,102 +0,0 @@ -package analyze_keys - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/selector" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -var ( - selectedItemStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(lipgloss.AdaptiveColor{Dark: styles.Colors["sprout"], Light: styles.Colors["bronze"]}). - Foreground(lipgloss.AdaptiveColor{Dark: styles.Colors["sprout"], Light: styles.Colors["fern"]}). - Padding(0, 0, 0, 1) - - titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFDF5")). - Background(lipgloss.Color(styles.Colors["bronze"])). - Padding(0, 1) -) - -type AnalyzeKeyPage struct { - common.Common - list list.Model -} - -func (ui *AnalyzeKeyPage) Init() tea.Cmd { - return nil -} - -func New(c common.Common) *AnalyzeKeyPage { - items := make([]list.Item, len(analyzers.AvailableAnalyzers())) - for i, analyzerType := range analyzers.AvailableAnalyzers() { - items[i] = KeyTypeItem(analyzerType) - } - delegate := list.NewDefaultDelegate() - delegate.ShowDescription = false - delegate.SetSpacing(0) - delegate.Styles.SelectedTitle = selectedItemStyle - - list := list.New(items, delegate, c.Width, c.Height) - list.Title = "Select an analyzer type" - list.SetShowStatusBar(false) - list.Styles.Title = titleStyle - return &AnalyzeKeyPage{ - Common: c, - list: list, - } -} - -func (ui *AnalyzeKeyPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if !ui.list.SettingFilter() { - switch msg := msg.(type) { - - case tea.WindowSizeMsg: - h, v := styles.AppStyle.GetFrameSize() - ui.list.SetSize(msg.Width-h, msg.Height-v) - case tea.KeyMsg: - switch { - case key.Matches(msg, ui.Common.KeyMap.Back): - return nil, tea.Quit - case key.Matches(msg, ui.Common.KeyMap.Select): - chosenAnalyzer := ui.list.SelectedItem().(KeyTypeItem) - - return ui, func() tea.Msg { - return selector.SelectMsg{IdentifiableItem: chosenAnalyzer} - } - } - } - - var cmd tea.Cmd - ui.list, cmd = ui.list.Update(msg) - return ui, cmd - } - return ui, func() tea.Msg { return nil } -} - -func (ui AnalyzeKeyPage) View() string { - return styles.AppStyle.Render(ui.list.View()) -} - -type KeyTypeItem string - -func (i KeyTypeItem) ID() string { return string(i) } -func (i KeyTypeItem) Title() string { return string(i) } -func (i KeyTypeItem) Description() string { return "" } -func (i KeyTypeItem) FilterValue() string { return string(i) } - -func (m AnalyzeKeyPage) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m AnalyzeKeyPage) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/pages/analyzer-form/analyzer_form.go b/pkg/tui/pages/analyzer-form/analyzer_form.go new file mode 100644 index 000000000000..a6a2ae7240f0 --- /dev/null +++ b/pkg/tui/pages/analyzer-form/analyzer_form.go @@ -0,0 +1,151 @@ +// Package analyzerform is the per-analyzer credential entry form. On +// submit it hands control to analyzer.Run via app.RunAnalyzerMsg. +package analyzerform + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/app" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// ID is the stable page id. +const ID = app.PageAnalyzerForm + +// Data is the payload passed via PushMsg to seed the form. +type Data struct { + KeyType string +} + +// Page is the tea.Model implementation. +type Page struct { + styles *theme.Styles + keymap *theme.KeyMap + keyType string + form *form.Form +} + +// New constructs an analyzer form for the given key type. Unknown key types +// fall back to a single-secret form. +func New(styles *theme.Styles, keymap *theme.KeyMap, data any) *Page { + d, _ := data.(Data) + return &Page{ + styles: styles, + keymap: keymap, + keyType: d.KeyType, + form: form.New(specsFor(d.KeyType)), + } +} + +// specsFor returns the field specs for a given analyzer key type. +func specsFor(keyType string) []form.FieldSpec { + secret := form.FieldSpec{Key: "key", Label: "Secret", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}} + logFile := form.FieldSpec{Key: "log_file", Label: "Log file", Help: "Log HTTP requests that analysis performs to this file", Kind: form.KindText} + + var specs []form.FieldSpec + switch strings.ToLower(keyType) { + case "twilio": + specs = []form.FieldSpec{ + {Key: "sid", Label: "SID", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + {Key: "key", Label: "Token", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + } + case "shopify": + specs = []form.FieldSpec{ + {Key: "key", Label: "Secret", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + {Key: "url", Label: "Shopify URL", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + } + case "dockerhub": + specs = []form.FieldSpec{ + {Key: "username", Label: "Username", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + {Key: "pat", Label: "Token (PAT)", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + } + case "planetscale": + specs = []form.FieldSpec{ + {Key: "id", Label: "Service Id", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + {Key: "token", Label: "Service Token", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + } + case "plaid": + specs = []form.FieldSpec{ + {Key: "secret", Label: "Secret", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + {Key: "id", Label: "Client ID", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + {Key: "token", Label: "Access Token", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + } + case "datadog": + specs = []form.FieldSpec{ + {Key: "api_key", Label: "API Key", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + {Key: "app_key", Label: "Application Key", Kind: form.KindSecret}, + {Key: "endpoint", Label: "Endpoint", Help: "Leave empty to auto-detect", Kind: form.KindText}, + } + case "mux": + specs = []form.FieldSpec{ + {Key: "secret", Label: "Secret", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + {Key: "key", Label: "Key", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + } + case "databricks": + specs = []form.FieldSpec{ + {Key: "token", Label: "Access Token", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + {Key: "domain", Label: "Domain", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + } + case "jira": + specs = []form.FieldSpec{ + {Key: "domain", Label: "Domain", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + {Key: "email", Label: "Email", Kind: form.KindText, Validators: []form.Validate{form.Required()}}, + {Key: "token", Label: "Token", Kind: form.KindSecret, Validators: []form.Validate{form.Required()}}, + } + default: + specs = []form.FieldSpec{secret} + } + return append(specs, logFile) +} + +func (p *Page) ID() app.PageID { return ID } + +func (p *Page) Init() tea.Cmd { return nil } + +func (p *Page) Update(msg tea.Msg) (app.Page, tea.Cmd) { + switch msg := msg.(type) { + case app.ResizeMsg: + p.form.Resize(msg.Width, msg.Height) + return p, nil + case form.SubmitMsg: + return p, p.runAnalyzer(msg.Values) + } + f, cmd := p.form.Update(msg) + p.form = f + return p, cmd +} + +func (p *Page) runAnalyzer(values map[string]string) tea.Cmd { + logFile := values["log_file"] + cfg := config.Config{LogFile: logFile, LoggingEnabled: logFile != ""} + info := analyzer.SecretInfo{Cfg: &cfg, Parts: values} + return func() tea.Msg { + return app.RunAnalyzerMsg{Type: p.keyType, Info: info} + } +} + +func (p *Page) View() string { + var b strings.Builder + b.WriteString(p.styles.Title.Render("Configuring " + p.keyType + " analyzer")) + b.WriteString("\n\n") + b.WriteString(p.form.View()) + b.WriteString("\n⚠️ Running TruffleHog Analyze will send a lot of requests ⚠️\n") + b.WriteString("🚧 Please confirm you have permission to run this analyzer 🚧\n") + return b.String() +} + +func (p *Page) SetSize(width, height int) { p.form.Resize(width, height) } + +func (p *Page) Help() []key.Binding { + return []key.Binding{p.keymap.UpDown, p.keymap.Select} +} + +// AllowQKey is false because the form accepts free-text input that may +// include the letter q. +func (p *Page) AllowQKey() bool { return false } diff --git a/pkg/tui/pages/analyzer-picker/analyzer_picker.go b/pkg/tui/pages/analyzer-picker/analyzer_picker.go new file mode 100644 index 000000000000..1a7e8990aa14 --- /dev/null +++ b/pkg/tui/pages/analyzer-picker/analyzer_picker.go @@ -0,0 +1,88 @@ +// Package analyzerpicker is the list of available analyzer types. +package analyzerpicker + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/app" + analyzerform "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/analyzer-form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// ID is the stable page id. +const ID = app.PageAnalyzerPicker + +type item string + +func (i item) FilterValue() string { return string(i) } +func (i item) Title() string { return string(i) } +func (i item) Description() string { return "" } + +// Page is the tea.Model implementation. +type Page struct { + list list.Model + styles *theme.Styles + keymap *theme.KeyMap +} + +// New constructs the analyzer picker. +func New(styles *theme.Styles, keymap *theme.KeyMap, _ any) *Page { + all := analyzers.AvailableAnalyzers() + items := make([]list.Item, len(all)) + for i, a := range all { + items[i] = item(a) + } + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = false + delegate.SetSpacing(0) + l := list.New(items, delegate, 0, 0) + l.Title = "Select an analyzer type" + l.Styles.Title = styles.Title + l.SetShowStatusBar(false) + return &Page{list: l, styles: styles, keymap: keymap} +} + +func (p *Page) ID() app.PageID { return ID } + +func (p *Page) Init() tea.Cmd { return nil } + +func (p *Page) Update(msg tea.Msg) (app.Page, tea.Cmd) { + switch msg := msg.(type) { + case app.ResizeMsg: + p.list.SetSize(msg.Width, msg.Height) + return p, nil + case tea.KeyMsg: + if p.list.SettingFilter() { + break + } + if key.Matches(msg, p.keymap.Select) { + sel, ok := p.list.SelectedItem().(item) + if !ok { + return p, nil + } + return p, func() tea.Msg { + return app.PushMsg{ID: app.PageAnalyzerForm, Data: analyzerform.Data{ + KeyType: string(sel), + }} + } + } + } + var cmd tea.Cmd + p.list, cmd = p.list.Update(msg) + return p, cmd +} + +func (p *Page) View() string { return p.list.View() } + +func (p *Page) SetSize(width, height int) { + p.list.SetSize(width, height) +} + +func (p *Page) Help() []key.Binding { + return []key.Binding{p.keymap.UpDown, p.keymap.Select} +} + +func (p *Page) AllowQKey() bool { return false } diff --git a/pkg/tui/pages/contact_enterprise/contact_enterprise.go b/pkg/tui/pages/contact_enterprise/contact_enterprise.go deleted file mode 100644 index 83ada4a38cc6..000000000000 --- a/pkg/tui/pages/contact_enterprise/contact_enterprise.go +++ /dev/null @@ -1,60 +0,0 @@ -package contact_enterprise - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -type ContactEnterprise struct { - common.Common - viewed bool -} - -var ( - linkStyle = lipgloss.NewStyle().Foreground( - lipgloss.Color("28")) // green -) - -func New(c common.Common) *ContactEnterprise { - return &ContactEnterprise{ - Common: c, - viewed: false, - } -} - -func (m *ContactEnterprise) Init() tea.Cmd { - return nil -} - -func (m *ContactEnterprise) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.viewed { - return m, tea.Quit - } - - return m, func() tea.Msg { return nil } -} - -func (m *ContactEnterprise) View() string { - - s := strings.Builder{} - s.WriteString("Interested in TruffleHog enterprise?\n") - s.WriteString(linkStyle.Render("🔗 https://trufflesecurity.com/contact")) - - m.viewed = true - return styles.AppStyle.Render(s.String()) -} - -func (m *ContactEnterprise) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m *ContactEnterprise) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/pages/link-card/link_card.go b/pkg/tui/pages/link-card/link_card.go new file mode 100644 index 000000000000..aac6ace56ffe --- /dev/null +++ b/pkg/tui/pages/link-card/link_card.go @@ -0,0 +1,66 @@ +// Package linkcard is the shared single-link landing page (OSS repo + +// Enterprise contact). The only state is the text + the URL, so both cards +// share one implementation. +package linkcard + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/app" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// ID is the stable page id. +const ID = app.PageLinkCard + +// Data is the payload passed via PushMsg to seed a link card. +type Data struct { + Title string + URL string +} + +// Page is the tea.Model implementation. +type Page struct { + data Data + styles *theme.Styles +} + +// New constructs a link card. Data must be a Data value; zero value is +// handled gracefully. +func New(styles *theme.Styles, data any) *Page { + d, _ := data.(Data) + return &Page{data: d, styles: styles} +} + +func (p *Page) ID() app.PageID { return ID } + +// Init schedules the router to exit once the first frame of the card has +// been rendered. bubbletea runs this page's View before flushing queued +// commands, so the link is painted into the terminal's scrollback before +// the program quits and the user sees it sitting where it was left. +func (p *Page) Init() tea.Cmd { + return func() tea.Msg { return app.ExitMsg{Args: nil} } +} + +func (p *Page) Update(msg tea.Msg) (app.Page, tea.Cmd) { return p, nil } + +func (p *Page) View() string { + link := lipgloss.NewStyle(). + Foreground(theme.ColorLink). + Render("🔗 " + p.data.URL) + var b strings.Builder + b.WriteString(p.data.Title) + b.WriteString("\n") + b.WriteString(link) + return b.String() +} + +func (p *Page) SetSize(width, height int) {} + +func (p *Page) Help() []key.Binding { return nil } + +func (p *Page) AllowQKey() bool { return true } diff --git a/pkg/tui/pages/source-config/source_config.go b/pkg/tui/pages/source-config/source_config.go new file mode 100644 index 000000000000..9c4a27374352 --- /dev/null +++ b/pkg/tui/pages/source-config/source_config.go @@ -0,0 +1,286 @@ +// Package sourceconfig hosts the three-tab source configuration flow: +// per-source fields, TruffleHog-wide flags, then a review/run step that +// hands control back to kingpin via app.ExitMsg. +package sourceconfig + +import ( + "runtime" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/app" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// ID is the stable page id. +const ID = app.PageSourceConfig + +// Data is the payload passed via PushMsg to seed the page. SourceID is the +// registry id (e.g. "github", "gcs"); the source picker is expected to have +// resolved the user's selection before pushing. +type Data struct { + SourceID string +} + +type tabIndex int + +const ( + tabSource tabIndex = iota + tabTrufflehog + tabRun +) + +var tabLabels = []string{ + "1. Source Configuration", + "2. TruffleHog Configuration", + "3. Run", +} + +// Page is the tea.Model implementation. +type Page struct { + styles *theme.Styles + keymap *theme.KeyMap + + def sources.Definition + sourceAdapter *sources.FormAdapter + truffleForm *form.Form + + active tabIndex + width int + height int + unknown bool +} + +// New constructs a source-config page. If the referenced source isn't in +// the registry the page renders a "not found" error and nothing else. +func New(styles *theme.Styles, keymap *theme.KeyMap, data any) *Page { + d, _ := data.(Data) + def, ok := sources.Get(d.SourceID) + if !ok { + return &Page{styles: styles, keymap: keymap, unknown: true} + } + return &Page{ + styles: styles, + keymap: keymap, + def: def, + sourceAdapter: sources.NewFormAdapter(def), + truffleForm: newTruffleForm(), + } +} + +// newTruffleForm builds the TruffleHog-wide flags form. Booleans render as +// checkboxes; --only-verified expands to --results=verified. +func newTruffleForm() *form.Form { + specs := []form.FieldSpec{ + {Key: "json", Label: "JSON output", Help: "Output results as JSON", Kind: form.KindCheckbox, Emit: form.EmitPresence}, + {Key: "no-verification", Label: "Skip Verification", Help: "Skip checking if suspected secrets are real", Kind: form.KindCheckbox, Emit: form.EmitPresence}, + {Key: "only-verified", Label: "Verified results only", Help: "Return only verified results", Kind: form.KindCheckbox, Emit: form.EmitConstant, Constant: []string{"--results=verified"}}, + {Key: "exclude-detectors", Label: "Exclude detectors", Help: "Comma-separated detector IDs to exclude.", Kind: form.KindText, Emit: form.EmitLongFlagEq, Transform: stripSpaces}, + {Key: "concurrency", Label: "Concurrency", Help: "Number of concurrent workers", Kind: form.KindText, Placeholder: strconv.Itoa(runtime.NumCPU()), Emit: form.EmitLongFlagEq, Validators: []form.Validate{form.Integer(1, 1 << 20)}}, + } + return form.New(specs) +} + +func stripSpaces(v string) string { return strings.ReplaceAll(v, " ", "") } + +func (p *Page) ID() app.PageID { return ID } + +func (p *Page) Init() tea.Cmd { return nil } + +func (p *Page) Update(msg tea.Msg) (app.Page, tea.Cmd) { + if p.unknown { + return p, nil + } + switch msg := msg.(type) { + case app.ResizeMsg: + p.width = msg.Width + p.height = msg.Height + p.sourceAdapter.Resize(msg.Width, msg.Height) + p.truffleForm.Resize(msg.Width, msg.Height) + return p, nil + case tea.KeyMsg: + if key.Matches(msg, p.keymap.Section) { + p.advanceTab(msg.String() == "shift+tab") + return p, nil + } + if p.active == tabRun && msg.Type == tea.KeyEnter { + return p, p.runCmd() + } + case form.SubmitMsg: + p.advanceTab(false) + return p, nil + } + return p.forwardToActive(msg) +} + +// advanceTab cycles the active tab with wraparound: forward past the last +// tab returns to the first, and back past the first returns to the last. +func (p *Page) advanceTab(back bool) { + n := tabIndex(len(tabLabels)) + if back { + p.active = (p.active - 1 + n) % n + } else { + p.active = (p.active + 1) % n + } +} + +func (p *Page) forwardToActive(msg tea.Msg) (app.Page, tea.Cmd) { + switch p.active { + case tabSource: + _, cmd := p.sourceAdapter.Update(msg) + return p, cmd + case tabTrufflehog: + f, cmd := p.truffleForm.Update(msg) + p.truffleForm = f + return p, cmd + } + return p, nil +} + +// truffleCmd returns the truffle flag tokens, delegating to the shared +// form.BuildArgs via form.Args(). +func (p *Page) truffleCmd() []string { return p.truffleForm.Args() } + +// runCmd validates both forms before emitting an ExitMsg. If either form +// has a validation error, the page jumps to the offending tab (so the +// per-field error messages Valid() just stored are visible) and no exit +// message is emitted. This prevents tab-to-run-and-enter from shipping an +// incomplete argv to kingpin. +func (p *Page) runCmd() tea.Cmd { + if !p.sourceAdapter.Valid() { + p.active = tabSource + return nil + } + if !p.truffleForm.Valid() { + p.active = tabTrufflehog + return nil + } + args := append([]string{}, p.sourceAdapter.Cmd()...) + args = append(args, p.truffleCmd()...) + return func() tea.Msg { return app.ExitMsg{Args: args} } +} + +func (p *Page) View() string { + if p.unknown { + return p.styles.Error.Render("Unknown source.") + } + var b strings.Builder + b.WriteString(p.renderTabs()) + b.WriteString("\n\n") + switch p.active { + case tabSource: + b.WriteString(p.sourceView()) + case tabTrufflehog: + b.WriteString(p.truffleView()) + case tabRun: + b.WriteString(p.runView()) + } + return b.String() +} + +func (p *Page) renderTabs() string { + parts := make([]string, 0, len(tabLabels)*2) + for i, label := range tabLabels { + style := p.styles.TabInactive + if tabIndex(i) == p.active { + style = p.styles.TabActive + } + parts = append(parts, style.Render(label)) + if i != len(tabLabels)-1 { + parts = append(parts, p.styles.TabSeparator.String()) + } + } + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} + +func (p *Page) sourceView() string { + var b strings.Builder + b.WriteString(p.styles.Bold.Render("Configuring ")) + b.WriteString(p.styles.Primary.Render(p.def.Title)) + b.WriteString("\n") + b.WriteString(p.styles.Hint.Render("* required field")) + b.WriteString("\n\n") + if p.def.Note != "" { + b.WriteString("⭐ " + p.def.Note + " ⭐\n\n") + } + b.WriteString(p.sourceAdapter.View()) + return b.String() +} + +func (p *Page) truffleView() string { + var b strings.Builder + b.WriteString(p.styles.Bold.Render("Configuring ")) + b.WriteString(p.styles.Primary.Render("TruffleHog")) + b.WriteString("\n") + b.WriteString(p.styles.Hint.Render("You can skip this and run with defaults")) + b.WriteString("\n\n") + b.WriteString(p.truffleForm.View()) + return b.String() +} + +func (p *Page) runView() string { + var b strings.Builder + b.WriteString(p.styles.Bold.Render("Ready to run TruffleHog for " + p.def.Title)) + b.WriteString("\n\n🔎 Source configuration\n") + b.WriteString(indentOrDefault(p.sourceAdapter.Summary())) + b.WriteString("\n🐽 TruffleHog configuration\n") + b.WriteString(indentOrDefault(p.truffleForm.Summary())) + b.WriteString("\nGenerated command:\n") + b.WriteString(p.styles.Code.Render(renderCommand(p.sourceAdapter.Cmd(), p.truffleCmd()))) + b.WriteString("\n\n") + b.WriteString(p.styles.Primary.Render("[ press enter to run ]")) + return b.String() +} + +func indentOrDefault(s string) string { + s = strings.TrimRight(s, "\n") + if s == "" { + return "\tRunning with defaults\n" + } + return s + "\n" +} + +// renderCommand prints the final argv with single-quoted tokens when they +// contain whitespace so users can copy/paste the result. +func renderCommand(source, truffle []string) string { + parts := []string{"trufflehog"} + parts = append(parts, quoteTokens(source)...) + parts = append(parts, quoteTokens(truffle)...) + return strings.Join(parts, " ") +} + +func quoteTokens(ts []string) []string { + out := make([]string, 0, len(ts)) + for _, t := range ts { + if strings.ContainsAny(t, " \t") { + out = append(out, "'"+t+"'") + continue + } + out = append(out, t) + } + return out +} + +func (p *Page) SetSize(width, height int) { + p.width = width + p.height = height + if p.sourceAdapter != nil { + p.sourceAdapter.Resize(width, height) + } + if p.truffleForm != nil { + p.truffleForm.Resize(width, height) + } +} + +func (p *Page) Help() []key.Binding { + return []key.Binding{p.keymap.UpDown, p.keymap.Section, p.keymap.Select} +} + +// AllowQKey is false because every tab in this flow accepts free text. +func (p *Page) AllowQKey() bool { return false } diff --git a/pkg/tui/pages/source-picker/source_picker.go b/pkg/tui/pages/source-picker/source_picker.go new file mode 100644 index 000000000000..ce58cd1d91c2 --- /dev/null +++ b/pkg/tui/pages/source-picker/source_picker.go @@ -0,0 +1,142 @@ +// Package sourcepicker is the source selection list. Selecting an OSS source +// pushes source-config; selecting an Enterprise source pushes a contact link +// card. +package sourcepicker + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/app" + linkcard "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/link-card" + sourceconfig "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/source-config" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// ID is the stable page id. +const ID = app.PageSourcePicker + +// enterpriseSources is the advertised-only list. The registry only contains +// OSS sources; these are appended to the list so users can see what's +// available behind the paywall. +var enterpriseSources = []sources.Definition{ + {Title: "Artifactory", Description: "Scan JFrog Artifactory packages.", Tier: sources.TierEnterprise}, + {Title: "Azure Repos", Description: "Scan Microsoft Azure repositories.", Tier: sources.TierEnterprise}, + {Title: "BitBucket", Description: "Scan Atlassian's Git-based source code repository hosting service.", Tier: sources.TierEnterprise}, + {Title: "Buildkite", Description: "Scan Buildkite, a CI/CD platform.", Tier: sources.TierEnterprise}, + {Title: "Confluence", Description: "Scan Atlassian's web-based wiki and knowledge base.", Tier: sources.TierEnterprise}, + {Title: "Gerrit", Description: "Scan Gerrit, a code collaboration tool", Tier: sources.TierEnterprise}, + {Title: "Jira", Description: "Scan Atlassian's issue & project tracking software.", Tier: sources.TierEnterprise}, + {Title: "Slack", Description: "Scan Slack, a messaging and communication platform.", Tier: sources.TierEnterprise}, + {Title: "Microsoft Teams", Description: "Scan Microsoft Teams, a messaging and communication platform.", Tier: sources.TierEnterprise}, + {Title: "Microsoft Sharepoint", Description: "Scan Microsoft Sharepoint, a collaboration and document management platform.", Tier: sources.TierEnterprise}, + {Title: "Google Drive", Description: "Scan Google Drive, a cloud-based storage and file sync service.", Tier: sources.TierEnterprise}, +} + +type item struct { + def sources.Definition +} + +func (i item) FilterValue() string { return i.def.Title + i.def.Description } + +func (i item) Title() string { + if i.def.Tier == sources.TierEnterprise { + return "💸 " + i.def.Title + } + return i.def.Title +} + +func (i item) Description() string { + if i.def.Tier == sources.TierEnterprise { + return i.def.Description + " (Enterprise only)" + } + return i.def.Description +} + +// Page is the tea.Model implementation. +type Page struct { + list list.Model + styles *theme.Styles + keymap *theme.KeyMap +} + +// New constructs the source picker. +func New(styles *theme.Styles, keymap *theme.KeyMap, _ any) *Page { + items := buildItems() + delegate := list.NewDefaultDelegate() + l := list.New(items, delegate, 0, 0) + l.Title = "Sources" + l.Styles.Title = styles.Title + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + return &Page{list: l, styles: styles, keymap: keymap} +} + +func buildItems() []list.Item { + defs := sources.All() + out := make([]list.Item, 0, len(defs)+len(enterpriseSources)) + for _, d := range defs { + out = append(out, item{def: d}) + } + for _, d := range enterpriseSources { + out = append(out, item{def: d}) + } + return out +} + +func (p *Page) ID() app.PageID { return ID } + +func (p *Page) Init() tea.Cmd { return nil } + +func (p *Page) Update(msg tea.Msg) (app.Page, tea.Cmd) { + switch msg := msg.(type) { + case app.ResizeMsg: + p.list.SetSize(msg.Width, msg.Height) + return p, nil + case tea.KeyMsg: + if p.list.FilterState() == list.Filtering { + break + } + if key.Matches(msg, p.keymap.Select) { + return p, p.choose() + } + } + var cmd tea.Cmd + p.list, cmd = p.list.Update(msg) + return p, cmd +} + +func (p *Page) choose() tea.Cmd { + selected, ok := p.list.SelectedItem().(item) + if !ok { + return nil + } + if selected.def.Tier == sources.TierEnterprise { + return func() tea.Msg { + return app.PushMsg{ID: app.PageLinkCard, Data: linkcard.Data{ + Title: "Interested in TruffleHog enterprise?", + URL: "https://trufflesecurity.com/contact", + }} + } + } + return func() tea.Msg { + return app.PushMsg{ID: app.PageSourceConfig, Data: sourceconfig.Data{ + SourceID: selected.def.ID, + }} + } +} + +func (p *Page) View() string { return p.list.View() } + +func (p *Page) SetSize(width, height int) { + p.list.SetSize(width, height) +} + +func (p *Page) Help() []key.Binding { + return []key.Binding{p.keymap.UpDown, p.keymap.Select} +} + +// AllowQKey is false so the list's filter-by-text still accepts "q". +func (p *Page) AllowQKey() bool { return false } diff --git a/pkg/tui/pages/source_configure/item.go b/pkg/tui/pages/source_configure/item.go deleted file mode 100644 index 70110b673b06..000000000000 --- a/pkg/tui/pages/source_configure/item.go +++ /dev/null @@ -1,23 +0,0 @@ -package source_configure - -type Item struct { - title string - description string -} - -func (i Item) ID() string { return i.title } - -func (i Item) Title() string { - return i.title -} -func (i Item) Description() string { - return i.description -} - -func (i Item) SetDescription(d string) Item { - i.description = d - return i -} - -// We shouldn't be filtering for these list items. -func (i Item) FilterValue() string { return "" } diff --git a/pkg/tui/pages/source_configure/run_component.go b/pkg/tui/pages/source_configure/run_component.go deleted file mode 100644 index db700259fb31..000000000000 --- a/pkg/tui/pages/source_configure/run_component.go +++ /dev/null @@ -1,118 +0,0 @@ -package source_configure - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -type SetArgsMsg string - -type RunComponent struct { - common.Common - parent *SourceConfigure - reviewList list.Model - reviewListItems []list.Item -} - -func NewRunComponent(common common.Common, parent *SourceConfigure) *RunComponent { - // Make list of SourceItems. - listItems := []list.Item{ - Item{title: "🔎 Source configuration"}, - Item{title: "🐽 TruffleHog configuration"}, - Item{title: "💸 Sales pitch", description: "\tContinuous monitoring, state tracking, remediations, and more\n\t🔗 https://trufflesecurity.com/trufflehog"}, - } - - // Setup list - delegate := list.NewDefaultDelegate() - delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("white")) - delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("white")) - delegate.SetHeight(3) - - reviewList := list.New(listItems, delegate, common.Width, common.Height) - - reviewList.SetShowTitle(false) - reviewList.SetShowStatusBar(false) - reviewList.SetFilteringEnabled(false) - - return &RunComponent{ - Common: common, - parent: parent, - reviewList: reviewList, - reviewListItems: listItems, - } -} - -func (m *RunComponent) Init() tea.Cmd { - return nil -} - -func (m *RunComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - h, v := styles.AppStyle.GetFrameSize() - m.reviewList.SetSize(msg.Width-h, msg.Height/2-v) - case tea.KeyMsg: - if msg.Type == tea.KeyEnter { - command := m.parent.sourceFields.Cmd() - if m.parent.truffleFields.Cmd() != "" { - command += " " + m.parent.truffleFields.Cmd() - } - cmd := func() tea.Msg { return SetArgsMsg(command) } - return m, cmd - } - } - if len(m.reviewListItems) > 0 && m.parent != nil && m.parent.sourceFields != nil { - m.reviewListItems[0] = m.reviewListItems[0].(Item).SetDescription(m.parent.sourceFields.Summary()) - m.reviewListItems[1] = m.reviewListItems[1].(Item).SetDescription(m.parent.truffleFields.Summary()) - } - var cmd tea.Cmd - m.reviewList, cmd = m.reviewList.Update(msg) - return m, tea.Batch(cmd) -} - -func (m *RunComponent) View() string { - var view strings.Builder - - view.WriteString("\n🔎 Source configuration\n") - view.WriteString(m.parent.sourceFields.Summary()) - - view.WriteString("\n🐽 TruffleHog configuration\n") - view.WriteString(m.parent.truffleFields.Summary()) - - view.WriteString("\n💸 Sales pitch\n") - view.WriteString("\tContinuous monitoring, state tracking, remediations, and more\n") - view.WriteString("\t🔗 https://trufflesecurity.com/trufflehog\n\n") - - view.WriteString(styles.BoldTextStyle.Render("\n\n🐷 Run TruffleHog for "+m.parent.configTabSource) + " 🐷\n\n") - - view.WriteString("Generated TruffleHog command\n") - view.WriteString(styles.HintTextStyle.Render("Save this if you want to run it again later!") + "\n") - - command := m.parent.sourceFields.Cmd() - if m.parent.truffleFields.Cmd() != "" { - command += " " + m.parent.truffleFields.Cmd() - } - view.WriteString(styles.CodeTextStyle.Render(command)) - - focusedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - view.WriteString("\n\n" + focusedStyle.Render("[ Run TruffleHog ]") + "\n\n") - - // view.WriteString(m.reviewList.View()) - return view.String() -} - -func (m *RunComponent) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m *RunComponent) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/pages/source_configure/source_component.go b/pkg/tui/pages/source_configure/source_component.go deleted file mode 100644 index f5ad69e3b6c9..000000000000 --- a/pkg/tui/pages/source_configure/source_component.go +++ /dev/null @@ -1,71 +0,0 @@ -package source_configure - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -type SourceComponent struct { - common.Common - parent *SourceConfigure - form tea.Model -} - -func NewSourceComponent(common common.Common, parent *SourceConfigure) *SourceComponent { - return &SourceComponent{ - Common: common, - parent: parent, - } -} - -func (m *SourceComponent) SetForm(form tea.Model) { - m.form = form -} - -func (m *SourceComponent) Init() tea.Cmd { - return nil -} - -func (m *SourceComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // TODO: Add a focus variable. - if m.form != nil { - model, cmd := m.form.Update(msg) - m.form = model - return m, cmd - } - return m, nil -} - -func (m *SourceComponent) View() string { - var view strings.Builder - - view.WriteString(styles.BoldTextStyle.Render("\nConfiguring "+styles.PrimaryTextStyle.Render(m.parent.configTabSource)) + "\n") - - view.WriteString(styles.HintTextStyle.Render("* required field") + "\n\n") - - sourceNote := sources.GetSourceNotes(m.parent.configTabSource) - if len(sourceNote) > 0 { - view.WriteString("⭐ " + sourceNote + " ⭐\n\n") - } - - if m.form != nil { - view.WriteString(m.form.View()) - view.WriteString("\n") - } - return view.String() -} - -func (m *SourceComponent) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m *SourceComponent) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/pages/source_configure/source_configure.go b/pkg/tui/pages/source_configure/source_configure.go deleted file mode 100644 index d543a3f01c1c..000000000000 --- a/pkg/tui/pages/source_configure/source_configure.go +++ /dev/null @@ -1,139 +0,0 @@ -package source_configure - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/tabs" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" -) - -type SetSourceMsg struct { - Source string -} - -type tab int - -const ( - configTab tab = iota - truffleConfigTab - runTab -) - -func (t tab) String() string { - return []string{ - "1. Source Configuration", - "2. TruffleHog Configuration", - "3. Run", - }[t] -} - -type SourceConfigure struct { - common.Common - activeTab tab - tabs *tabs.Tabs - configTabSource string - tabComponents []common.Component - sourceFields sources.CmdModel - truffleFields sources.CmdModel -} - -func (m SourceConfigure) Init() tea.Cmd { - return m.tabs.Init() -} - -func New(c common.Common) *SourceConfigure { - conf := SourceConfigure{Common: c, truffleFields: GetTrufflehogConfiguration()} - conf.tabs = tabs.New(c, []string{configTab.String(), truffleConfigTab.String(), runTab.String()}) - - conf.tabComponents = []common.Component{ - configTab: NewSourceComponent(c, &conf), - truffleConfigTab: NewTrufflehogComponent(c, &conf), - runTab: NewRunComponent(c, &conf), - } - return &conf -} - -func (m *SourceConfigure) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - for i := range m.tabComponents { - model, cmd := m.tabComponents[i].Update(msg) - m.tabComponents[i] = model.(common.Component) - cmds = append(cmds, cmd) - } - - case tabs.ActiveTabMsg: - m.activeTab = tab(msg) - t, cmd := m.tabs.Update(msg) - m.tabs = t.(*tabs.Tabs) - - if cmd != nil { - cmds = append(cmds, cmd) - } - case tabs.SelectTabMsg: - m.activeTab = tab(msg) - t, cmd := m.tabs.Update(msg) - m.tabs = t.(*tabs.Tabs) - - if cmd != nil { - cmds = append(cmds, cmd) - } - case tea.KeyMsg: - t, cmd := m.tabs.Update(msg) - m.tabs = t.(*tabs.Tabs) - if cmd != nil { - cmds = append(cmds, cmd) - } - case SetSourceMsg: - m.configTabSource = msg.Source - // TODO: Use actual messages or something? - m.tabComponents[truffleConfigTab].(*TrufflehogComponent).SetForm(m.truffleFields) - fields := sources.GetSourceFields(m.configTabSource) - - if fields != nil { - m.sourceFields = fields - m.tabComponents[configTab].(*SourceComponent).SetForm(fields) - } - - case textinputs.SelectNextMsg, textinputs.SelectSkipMsg: - if m.activeTab < runTab { - m.activeTab++ - } - t, cmd := m.tabs.Update(tabs.SelectTabMsg(int(m.activeTab))) - m.tabs = t.(*tabs.Tabs) - - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - tab, cmd := m.tabComponents[m.activeTab].Update(msg) - m.tabComponents[m.activeTab] = tab.(common.Component) - if cmd != nil { - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} - -func (m *SourceConfigure) View() string { - return lipgloss.JoinVertical(lipgloss.Top, - m.tabs.View(), - m.tabComponents[m.activeTab].View(), - ) -} - -func (m *SourceConfigure) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m *SourceConfigure) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/pages/source_configure/trufflehog_component.go b/pkg/tui/pages/source_configure/trufflehog_component.go deleted file mode 100644 index 13c8a56b3ed6..000000000000 --- a/pkg/tui/pages/source_configure/trufflehog_component.go +++ /dev/null @@ -1,66 +0,0 @@ -package source_configure - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -type TrufflehogComponent struct { - common.Common - parent *SourceConfigure - form tea.Model -} - -func NewTrufflehogComponent(common common.Common, parent *SourceConfigure) *TrufflehogComponent { - return &TrufflehogComponent{ - Common: common, - parent: parent, - } -} - -func (m *TrufflehogComponent) SetForm(form tea.Model) { - m.form = form -} - -func (m *TrufflehogComponent) Init() tea.Cmd { - return nil -} - -func (m *TrufflehogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // TODO: Add a focus variable. - if m.form != nil { - model, cmd := m.form.Update(msg) - m.form = model - - return m, cmd - } - return m, nil -} - -func (m *TrufflehogComponent) View() string { - var view strings.Builder - - view.WriteString(styles.BoldTextStyle.Render("\nConfiguring "+styles.PrimaryTextStyle.Render("TruffleHog")) + "\n") - view.WriteString(styles.HintTextStyle.Render("You can skip this completely and run with defaults") + "\n\n") - - if m.form != nil { - view.WriteString(m.form.View()) - view.WriteString("\n") - } - - return view.String() -} - -func (m *TrufflehogComponent) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m *TrufflehogComponent) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/pages/source_configure/trufflehog_configure.go b/pkg/tui/pages/source_configure/trufflehog_configure.go deleted file mode 100644 index c3de73d535b4..000000000000 --- a/pkg/tui/pages/source_configure/trufflehog_configure.go +++ /dev/null @@ -1,116 +0,0 @@ -package source_configure - -import ( - "runtime" - "strconv" - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" -) - -type truffleCmdModel struct { - textinputs.Model -} - -func GetTrufflehogConfiguration() truffleCmdModel { - verification := textinputs.InputConfig{ - Label: "Skip Verification", - Key: "no-verification", - Required: false, - Help: "Check if a suspected secret is real or not", - Placeholder: "false", - } - - verifiedResults := textinputs.InputConfig{ - Label: "Verified results", - Key: "only-verified", - Required: false, - Help: "Return only verified results", - Placeholder: "false", - } - - jsonOutput := textinputs.InputConfig{ - Label: "JSON output", - Key: "json", - Required: false, - Help: "Output results to JSON", - Placeholder: "false", - } - - excludeDetectors := textinputs.InputConfig{ - Label: "Exclude detectors", - Key: "exclude_detectors", - Required: false, - Help: "Comma separated list of detector types to exclude. Protobuf name or IDs may be used, as well as ranges. IDs defined here take precedence over the include list.", - Placeholder: "", - } - - concurrency := textinputs.InputConfig{ - Label: "Concurrency", - Key: "concurrency", - Required: false, - Help: "Number of concurrent workers.", - Placeholder: strconv.Itoa(runtime.NumCPU()), - } - - return truffleCmdModel{textinputs.New([]textinputs.InputConfig{jsonOutput, verification, verifiedResults, excludeDetectors, concurrency}).SetSkip(true)} -} - -func (m truffleCmdModel) Cmd() string { - var command []string - inputs := m.GetInputs() - - if isTrue(inputs["json"].Value) { - command = append(command, "--json") - } - - if isTrue(inputs["no-verification"].Value) { - command = append(command, "--no-verification") - } - - if isTrue(inputs["only-verified"].Value) { - command = append(command, "--results=verified") - } - - if inputs["exclude_detectors"].Value != "" { - cmd := "--exclude-detectors=" + strings.ReplaceAll(inputs["exclude_detectors"].Value, " ", "") - command = append(command, cmd) - } - - if inputs["concurrency"].Value != "" { - command = append(command, "--concurrency="+inputs["concurrency"].Value) - } - - return strings.Join(command, " ") -} - -func (m truffleCmdModel) Summary() string { - summary := strings.Builder{} - keys := []string{"no-verification", "only-verified", "json", "exclude_detectors", "concurrency"} - - inputs := m.GetInputs() - labels := m.GetLabels() - for _, key := range keys { - if inputs[key].Value != "" { - summary.WriteString("\t" + labels[key] + ": " + inputs[key].Value + "\n") - } - } - - if summary.Len() == 0 { - summary.WriteString("\tRunning with defaults\n") - - } - - summary.WriteString("\n") - return summary.String() -} - -func isTrue(val string) bool { - value := strings.ToLower(val) - isTrue, _ := strconv.ParseBool(value) - - if isTrue || value == "yes" || value == "y" { - return true - } - return false -} diff --git a/pkg/tui/pages/source_select/item.go b/pkg/tui/pages/source_select/item.go deleted file mode 100644 index dd57a1244480..000000000000 --- a/pkg/tui/pages/source_select/item.go +++ /dev/null @@ -1,32 +0,0 @@ -package source_select - -type SourceItem struct { - title string - description string - enterprise bool -} - -func OssItem(title, description string) SourceItem { - return SourceItem{title, description, false} -} - -func EnterpriseItem(title, description string) SourceItem { - return SourceItem{title, description, true} -} - -func (i SourceItem) ID() string { return i.title } - -func (i SourceItem) Title() string { - if i.enterprise { - return "💸 " + i.title - } - return i.title -} -func (i SourceItem) Description() string { - if i.enterprise { - return i.description + " (Enterprise only)" - } - return i.description -} - -func (i SourceItem) FilterValue() string { return i.title + i.description } diff --git a/pkg/tui/pages/source_select/source_select.go b/pkg/tui/pages/source_select/source_select.go deleted file mode 100644 index b22de95f2d50..000000000000 --- a/pkg/tui/pages/source_select/source_select.go +++ /dev/null @@ -1,226 +0,0 @@ -package source_select - -import ( - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/selector" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -// TODO: Review light theme styling -var ( - titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFDF5")). - Background(lipgloss.Color(styles.Colors["bronze"])). - Padding(0, 1) - - // FIXME: Hon pls help - errorStatusMessageStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Dark: "#ff0000"}). - Render - - selectedSourceItemStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(lipgloss.AdaptiveColor{Dark: styles.Colors["sprout"], Light: styles.Colors["bronze"]}). - Foreground(lipgloss.AdaptiveColor{Dark: styles.Colors["sprout"], Light: styles.Colors["fern"]}). - Padding(0, 0, 0, 1) - - selectedDescription = selectedSourceItemStyle. - Foreground(lipgloss.AdaptiveColor{Dark: styles.Colors["sprout"], Light: styles.Colors["sprout"]}) -) - -type listKeyMap struct { - toggleHelpMenu key.Binding -} - -type ( - SourceSelect struct { - common.Common - sourcesList list.Model - keys *listKeyMap - delegateKeys *delegateKeyMap - selector *selector.Selector - } -) - -func New(c common.Common) *SourceSelect { - var ( - delegateKeys = newDelegateKeyMap() - listKeys = &listKeyMap{ - toggleHelpMenu: key.NewBinding( - key.WithKeys("H"), - key.WithHelp("H", "toggle help"), - ), - } - ) - - // Make list of SourceItems. - SourceItems := []list.Item{ - // Open source sources. - OssItem("Git", "Scan git repositories."), - OssItem("GitHub", "Scan GitHub repositories and/or organizations."), - OssItem("Filesystem", "Scan your filesystem by selecting what directories to scan."), - OssItem("Hugging Face", "Scan Hugging Face, an AI/ML community."), - OssItem("Jenkins", "Scan Jenkins, a CI/CD platform. (Recently open-sourced from enterprise!)"), - OssItem("Elasticsearch", "Scan your Elasticsearch cluster or Elastic Cloud instance."), - OssItem("Postman", "Scan a collection, workspace, or environment from Postman, the API platform."), - OssItem("GitLab", "Scan GitLab repositories."), - OssItem("AWS S3", "Scan Amazon S3 buckets."), - OssItem("CircleCI", "Scan CircleCI, a CI/CD platform."), - OssItem("Syslog", "Scan syslog, event data logs."), - OssItem("Docker", "Scan a Docker instance, a containerized application."), - OssItem("GCS (Google Cloud Storage)", "Scan a Google Cloud Storage instance."), - // Enterprise sources. - EnterpriseItem("Artifactory", "Scan JFrog Artifactory packages."), - EnterpriseItem("Azure Repos", "Scan Microsoft Azure repositories."), - EnterpriseItem("BitBucket", "Scan Atlassian's Git-based source code repository hosting service."), - EnterpriseItem("Buildkite", "Scan Buildkite, a CI/CD platform."), - EnterpriseItem("Confluence", "Scan Atlassian's web-based wiki and knowledge base."), - EnterpriseItem("Gerrit", "Scan Gerrit, a code collaboration tool"), - EnterpriseItem("Jira", "Scan Atlassian's issue & project tracking software."), - EnterpriseItem("Slack", "Scan Slack, a messaging and communication platform."), - EnterpriseItem("Microsoft Teams", "Scan Microsoft Teams, a messaging and communication platform."), - EnterpriseItem("Microsoft Sharepoint", "Scan Microsoft Sharepoint, a collaboration and document management platform."), - EnterpriseItem("Google Drive", "Scan Google Drive, a cloud-based storage and file sync service."), - } - - // Setup list - delegate := newSourceItemDelegate(delegateKeys) - delegate.Styles.SelectedTitle = selectedSourceItemStyle - delegate.Styles.SelectedDesc = selectedDescription - - sourcesList := list.New(SourceItems, delegate, 0, 0) - sourcesList.Title = "Sources" - sourcesList.Styles.Title = titleStyle - sourcesList.StatusMessageLifetime = 10 * time.Second - - sourcesList.AdditionalFullHelpKeys = func() []key.Binding { - return []key.Binding{ - listKeys.toggleHelpMenu, - } - } - - sourcesList.SetShowStatusBar(false) - sel := selector.New(c, []selector.IdentifiableItem{}, delegate) - - return &SourceSelect{ - Common: c, - sourcesList: sourcesList, - keys: listKeys, - delegateKeys: delegateKeys, - selector: sel, - } -} - -func (m *SourceSelect) Init() tea.Cmd { - return nil -} - -func (m *SourceSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - h, v := styles.AppStyle.GetFrameSize() - m.sourcesList.SetSize(msg.Width-h, msg.Height-v) - - case tea.KeyMsg: - // Don't match any of the keys below if we're actively filtering. - if m.sourcesList.FilterState() == list.Filtering { - break - } - - switch { - case key.Matches(msg, m.keys.toggleHelpMenu): - m.sourcesList.SetShowHelp(!m.sourcesList.ShowHelp()) - return m, nil - } - } - - // This will also call our delegate's update function. - newListModel, cmd := m.sourcesList.Update(msg) - m.sourcesList = newListModel - cmds = append(cmds, cmd) - - if m.selector != nil { - sel, cmd := m.selector.Update(msg) - m.selector = sel.(*selector.Selector) - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} - -func (m *SourceSelect) View() string { - return styles.AppStyle.Render(m.sourcesList.View()) -} - -func (m *SourceSelect) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m *SourceSelect) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} - -func newSourceItemDelegate(keys *delegateKeyMap) list.DefaultDelegate { - d := list.NewDefaultDelegate() - - d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd { - selectedSourceItem, ok := m.SelectedItem().(SourceItem) - if !ok { - return nil - } - - if msg, ok := msg.(tea.KeyMsg); ok && key.Matches(msg, keys.choose) { - if selectedSourceItem.enterprise { - return m.NewStatusMessage(errorStatusMessageStyle( - "That's an enterprise only source. Learn more at trufflesecurity.com", - )) - } - - return func() tea.Msg { - return selector.SelectMsg{IdentifiableItem: selectedSourceItem} - } - } - return nil - } - - help := []key.Binding{keys.choose} - d.ShortHelpFunc = func() []key.Binding { return help } - d.FullHelpFunc = func() [][]key.Binding { return [][]key.Binding{help} } - - return d -} - -type delegateKeyMap struct { - choose key.Binding -} - -// Additional short help entries. This satisfies the help.KeyMap interface and -// is entirely optional. -func (d delegateKeyMap) ShortHelp() []key.Binding { - return []key.Binding{d.choose} -} - -// Additional full help entries. This satisfies the help.KeyMap interface and -// is entirely optional. -func (d delegateKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{d.choose}} -} - -func newDelegateKeyMap() *delegateKeyMap { - return &delegateKeyMap{ - choose: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "choose"), - ), - } -} diff --git a/pkg/tui/pages/view_oss/view_oss.go b/pkg/tui/pages/view_oss/view_oss.go deleted file mode 100644 index 1382900334a6..000000000000 --- a/pkg/tui/pages/view_oss/view_oss.go +++ /dev/null @@ -1,59 +0,0 @@ -package view_oss - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -type ViewOSS struct { - common.Common - viewed bool -} - -var ( - linkStyle = lipgloss.NewStyle().Foreground( - lipgloss.Color("28")) // green -) - -func New(c common.Common) *ViewOSS { - return &ViewOSS{ - Common: c, - viewed: false, - } -} - -func (m *ViewOSS) Init() tea.Cmd { - return nil -} - -func (m *ViewOSS) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.viewed { - return m, tea.Quit - } - - return m, func() tea.Msg { return nil } -} - -func (m *ViewOSS) View() string { - s := strings.Builder{} - s.WriteString("View our open-source project on GitHub\n") - s.WriteString(linkStyle.Render("🔗 https://github.com/trufflesecurity/trufflehog ")) - - m.viewed = true - return styles.AppStyle.Render(s.String()) -} - -func (m *ViewOSS) ShortHelp() []key.Binding { - // TODO: actually return something - return nil -} - -func (m *ViewOSS) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/pages/wizard/wizard.go b/pkg/tui/pages/wizard/wizard.go new file mode 100644 index 000000000000..f7c23bd3dba3 --- /dev/null +++ b/pkg/tui/pages/wizard/wizard.go @@ -0,0 +1,145 @@ +// Package wizard is the root landing page: a list of top-level actions. +package wizard + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/app" + linkcard "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/link-card" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/theme" +) + +// ID is the stable page id. +const ID = app.PageWizard + +// action is a wizard menu entry. It is not exported; the page emits the +// appropriate navigation message when one is chosen. +type action int + +const ( + actionScan action = iota + actionAnalyze + actionHelp + actionOSS + actionEnterprise + actionQuit +) + +func (a action) label() string { + return []string{ + "Scan a source using wizard", + "Analyze a secret's permissions", + "View help docs", + "View open-source project", + "Inquire about TruffleHog Enterprise", + "Quit", + }[a] +} + +var allActions = []action{ + actionScan, actionAnalyze, actionHelp, + actionOSS, actionEnterprise, actionQuit, +} + +// Page is the tea.Model implementation for the root wizard. +type Page struct { + styles *theme.Styles + keymap *theme.KeyMap + cursor int +} + +// New constructs a wizard page. +func New(styles *theme.Styles, keymap *theme.KeyMap, _ any) *Page { + return &Page{styles: styles, keymap: keymap} +} + +func (p *Page) ID() app.PageID { return ID } + +func (p *Page) Init() tea.Cmd { return nil } + +func (p *Page) Update(msg tea.Msg) (app.Page, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return p, nil + } + switch { + case key.Matches(km, p.keymap.UpDown): + if km.String() == "up" || km.String() == "k" { + if p.cursor > 0 { + p.cursor-- + } + } else { + if p.cursor < len(allActions)-1 { + p.cursor++ + } + } + case key.Matches(km, p.keymap.Select): + return p, p.activate() + } + return p, nil +} + +func (p *Page) activate() tea.Cmd { + switch allActions[p.cursor] { + case actionScan: + return pushCmd(app.PageSourcePicker, nil) + case actionAnalyze: + return pushCmd(app.PageAnalyzerPicker, nil) + case actionHelp: + return exitCmd([]string{"--help"}) + case actionOSS: + return pushCmd(app.PageLinkCard, ossLinkData()) + case actionEnterprise: + return pushCmd(app.PageLinkCard, enterpriseLinkData()) + case actionQuit: + return exitCmd(nil) + } + return nil +} + +func (p *Page) View() string { + var b strings.Builder + b.WriteString("What do you want to do?\n\n") + for i, a := range allActions { + if i == p.cursor { + b.WriteString(p.styles.Primary.Render(" (•) " + a.label())) + } else { + b.WriteString(" ( ) " + a.label()) + } + b.WriteString("\n") + } + return b.String() +} + +func (p *Page) SetSize(width, height int) {} + +func (p *Page) Help() []key.Binding { + return []key.Binding{p.keymap.UpDown, p.keymap.Select} +} + +func (p *Page) AllowQKey() bool { return true } + +func pushCmd(id app.PageID, data any) tea.Cmd { + return func() tea.Msg { return app.PushMsg{ID: id, Data: data} } +} + +func exitCmd(args []string) tea.Cmd { + return func() tea.Msg { return app.ExitMsg{Args: args} } +} + +func ossLinkData() any { + return linkcard.Data{ + Title: "View our open-source project on GitHub", + URL: "https://github.com/trufflesecurity/trufflehog", + } +} + +func enterpriseLinkData() any { + return linkcard.Data{ + Title: "Interested in TruffleHog enterprise?", + URL: "https://trufflesecurity.com/contact", + } +} diff --git a/pkg/tui/pages/wizard_intro/item.go b/pkg/tui/pages/wizard_intro/item.go deleted file mode 100644 index 7fbefa704dec..000000000000 --- a/pkg/tui/pages/wizard_intro/item.go +++ /dev/null @@ -1,137 +0,0 @@ -package wizard_intro - -import ( - "fmt" - "io" - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" -) - -// Item represents a single item in the selector. -type Item int - -// ID implements selector.IdentifiableItem. -func (i Item) ID() string { - return i.String() -} - -// Title returns the item title. Implements list.DefaultItem. -func (i Item) Title() string { return i.String() } - -// Description returns the item description. Implements list.DefaultItem. -func (i Item) Description() string { return "" } - -// FilterValue implements list.Item. -func (i Item) FilterValue() string { return i.Title() } - -// Command returns the item Command view. -func (i Item) Command() string { - return i.Title() -} - -// ItemDelegate is the delegate for the item. -type ItemDelegate struct { - common *common.Common -} - -// Width returns the item width. -func (d ItemDelegate) Width() int { - width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth() - return width -} - -// Height returns the item height. Implements list.ItemDelegate. -func (d ItemDelegate) Height() int { - height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight() - return height -} - -// Spacing returns the spacing between items. Implements list.ItemDelegate. -func (d ItemDelegate) Spacing() int { return 1 } - -// Update implements list.ItemDelegate. -func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - idx := m.Index() - item, ok := m.SelectedItem().(Item) - if !ok { - return nil - } - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, d.common.KeyMap.Copy): - d.common.Copy.Copy(item.Command()) - return m.SetItem(idx, item) - } - } - return nil -} - -// Render implements list.ItemDelegate. -func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i := listItem.(Item) - s := strings.Builder{} - var matchedRunes []int - - // Conditions - var ( - isSelected = index == m.Index() - isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied - ) - - styles := d.common.Styles.RepoSelector.Normal - if isSelected { - styles = d.common.Styles.RepoSelector.Active - } - - title := i.Title() - title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize()) - // if i.repo.IsPrivate() { - // title += " 🔒" - // } - if isSelected { - title += " " - } - updatedStr := " Updated" - if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 { - updatedStr = "" - } - updatedStyle := styles.Updated. - Align(lipgloss.Right). - Width(m.Width() - styles.Base.GetHorizontalFrameSize() - lipgloss.Width(title)) - updated := updatedStyle.Render(updatedStr) - - if isFiltered && index < len(m.VisibleItems()) { - // Get indices of matched characters - matchedRunes = m.MatchesForItem(index) - } - - if isFiltered { - unmatched := styles.Title.Inline(true) - matched := unmatched.Underline(true) - title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) - } - title = styles.Title.Render(title) - desc := i.Description() - desc = common.TruncateString(desc, m.Width()-styles.Base.GetHorizontalFrameSize()) - desc = styles.Desc.Render(desc) - - s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated)) - s.WriteRune('\n') - s.WriteString(desc) - s.WriteRune('\n') - cmd := common.TruncateString(i.Command(), m.Width()-styles.Base.GetHorizontalFrameSize()) - cmd = styles.Command.Render(cmd) - - s.WriteString(cmd) - fmt.Fprint(w, - d.common.Zone.Mark(i.ID(), - styles.Base.Render(s.String()), - ), - ) -} diff --git a/pkg/tui/pages/wizard_intro/wizard_intro.go b/pkg/tui/pages/wizard_intro/wizard_intro.go deleted file mode 100644 index 4e54e9dfea1e..000000000000 --- a/pkg/tui/pages/wizard_intro/wizard_intro.go +++ /dev/null @@ -1,113 +0,0 @@ -package wizard_intro - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/selector" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -const ( - ScanSourceWithWizard Item = iota - // ScanSourceWithConfig - AnalyzeSecret - ViewHelpDocs - ViewOSSProject - EnterpriseInquire - Quit -) - -func (w Item) String() string { - switch w { - case ScanSourceWithWizard: - return "Scan a source using wizard" - case AnalyzeSecret: - return "Analyze a secret's permissions" - //case ScanSourceWithConfig: - // return "Scan a source with a config file" - case ViewHelpDocs: - return "View help docs" - case ViewOSSProject: - return "View open-source project" - case EnterpriseInquire: - return "Inquire about TruffleHog Enterprise" - case Quit: - return "Quit" - } - panic("unreachable") -} - -type WizardIntro struct { - common.Common - selector *selector.Selector -} - -func New(cmn common.Common) *WizardIntro { - sel := selector.New(cmn, - []selector.IdentifiableItem{ - ScanSourceWithWizard, - AnalyzeSecret, - // ScanSourceWithConfig, - ViewHelpDocs, - ViewOSSProject, - EnterpriseInquire, - Quit, - }, - ItemDelegate{&cmn}) - - return &WizardIntro{Common: cmn, selector: sel} -} - -func (m *WizardIntro) Init() tea.Cmd { - m.selector.Select(0) - return nil -} - -func (m *WizardIntro) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - - s, cmd := m.selector.Update(msg) - m.selector = s.(*selector.Selector) - if cmd != nil { - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} - -func (m *WizardIntro) View() string { - s := strings.Builder{} - s.WriteString("What do you want to do?\n\n") - - for i, selectorItem := range m.selector.Items() { - // Cast the interface to the concrete Item struct. - item := selectorItem.(Item) - if m.selector.Index() == i { - selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(styles.Colors["sprout"])) - s.WriteString(selectedStyle.Render(" (•) " + item.Title())) - } else { - s.WriteString(" ( ) " + item.Title()) - } - s.WriteString("\n") - } - - return styles.AppStyle.Render(s.String()) -} - -func (m *WizardIntro) ShortHelp() []key.Binding { - kb := make([]key.Binding, 0) - kb = append(kb, - m.Common.KeyMap.UpDown, - m.Common.KeyMap.Section, - ) - return kb -} - -func (m *WizardIntro) FullHelp() [][]key.Binding { - // TODO: actually return something - return nil -} diff --git a/pkg/tui/sources/adapter.go b/pkg/tui/sources/adapter.go new file mode 100644 index 000000000000..e79e6eb41e42 --- /dev/null +++ b/pkg/tui/sources/adapter.go @@ -0,0 +1,73 @@ +package sources + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" +) + +// FormAdapter wraps a Definition + form.Form so that the existing +// source_configure tab components can consume a uniform tea.Model shape while +// still getting a structured []string arg vector out of Cmd(). +// +// Cmd() returns the subcommand (Definition.Command) followed by the flag +// vector produced by the form and any ExtraArgs. This matches the pre-overhaul +// string contract where CmdModel.Cmd() included the subcommand token. +type FormAdapter struct { + def Definition + form *form.Form +} + +// NewFormAdapter builds an adapter for the given Definition. +func NewFormAdapter(def Definition) *FormAdapter { + return &FormAdapter{ + def: def, + form: form.New(def.Fields, def.Constraints...), + } +} + +// Definition returns the underlying Definition. +func (a *FormAdapter) Definition() Definition { return a.def } + +// Form returns the underlying form container. +func (a *FormAdapter) Form() *form.Form { return a.form } + +// Init satisfies tea.Model. +func (a *FormAdapter) Init() tea.Cmd { return nil } + +// Update delegates to the inner form. +func (a *FormAdapter) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + f, cmd := a.form.Update(msg) + a.form = f + return a, cmd +} + +// View renders the inner form. +func (a *FormAdapter) View() string { return a.form.View() } + +// Cmd returns the kingpin-style arg vector for this source's field values, +// prefixed with the Definition.Command subcommand token when set. +func (a *FormAdapter) Cmd() []string { + var args []string + if a.def.Command != "" { + args = append(args, a.def.Command) + } + if a.def.BuildArgs != nil { + args = append(args, a.def.BuildArgs(a.form.Values())...) + } else { + args = append(args, a.form.Args()...) + } + if len(a.def.ExtraArgs) > 0 { + args = append(args, a.def.ExtraArgs...) + } + return args +} + +// Summary returns the human-readable recap for this source. +func (a *FormAdapter) Summary() string { return a.form.Summary() } + +// Valid reports whether the underlying form validates. +func (a *FormAdapter) Valid() bool { return a.form.Valid() } + +// Resize forwards to the form. +func (a *FormAdapter) Resize(w, h int) { a.form.Resize(w, h) } diff --git a/pkg/tui/sources/circleci/circleci.go b/pkg/tui/sources/circleci/circleci.go index db8b7bfd4b6a..ce4bfb3958b0 100644 --- a/pkg/tui/sources/circleci/circleci.go +++ b/pkg/tui/sources/circleci/circleci.go @@ -1,41 +1,29 @@ package circleci import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type circleCiCmdModel struct { - textinputs.Model -} - -func GetFields() circleCiCmdModel { - token := textinputs.InputConfig{ - Label: "API Token", - Key: "token", - Required: true, - Placeholder: "top secret token", +func init() { sources.Register(Definition()) } + +// Definition returns the circleci source configuration. +func Definition() sources.Definition { + return sources.Definition{ + ID: "circleci", + Title: "CircleCI", + Description: "Scan CircleCI, a CI/CD platform.", + Tier: sources.TierOSS, + Command: "circleci", + Fields: []form.FieldSpec{ + { + Key: "token", + Label: "API Token", + Kind: form.KindSecret, + Placeholder: "top secret token", + Emit: form.EmitLongFlagEq, + Validators: []form.Validate{form.Required()}, + }, + }, } - - return circleCiCmdModel{textinputs.New([]textinputs.InputConfig{token})} -} - -func (m circleCiCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "circleci") - - inputs := m.GetInputs() - command = append(command, "--token="+inputs["token"].Value) - - return strings.Join(command, " ") -} - -func (m circleCiCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - keys := []string{"token"} - - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/docker/docker.go b/pkg/tui/sources/docker/docker.go index 32b407afba30..da8e7d59929a 100644 --- a/pkg/tui/sources/docker/docker.go +++ b/pkg/tui/sources/docker/docker.go @@ -1,50 +1,33 @@ package docker import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type dockerCmdModel struct { - textinputs.Model -} - -func GetFields() dockerCmdModel { - images := textinputs.InputConfig{ - Label: "Docker image(s)", - Key: "images", - Required: true, - Help: "Separate by space if multiple.", - Placeholder: "trufflesecurity/secrets", - } - - return dockerCmdModel{textinputs.New([]textinputs.InputConfig{images})} -} - -func (m dockerCmdModel) Cmd() string { - - var command []string - command = append(command, "trufflehog", "docker") - - inputs := m.GetInputs() - vals := inputs["images"].Value - - if vals != "" { - images := strings.Fields(vals) - for _, image := range images { - command = append(command, "--image="+image) - } +func init() { sources.Register(Definition()) } + +// Definition returns the docker source configuration. +// +// The user enters one or more image references separated by whitespace; the +// form emits one --image= per image, matching the original behavior. +func Definition() sources.Definition { + return sources.Definition{ + ID: "docker", + Title: "Docker", + Description: "Scan a Docker instance, a containerized application.", + Tier: sources.TierOSS, + Command: "docker", + Fields: []form.FieldSpec{ + { + Key: "image", + Label: "Docker image(s)", + Help: "Separate by space if multiple.", + Kind: form.KindText, + Placeholder: "trufflesecurity/secrets", + Emit: form.EmitRepeatedLongFlagEq, + Validators: []form.Validate{form.Required()}, + }, + }, } - - return strings.Join(command, " ") -} - -func (m dockerCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - keys := []string{"images"} - - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/elasticsearch/elasticsearch.go b/pkg/tui/sources/elasticsearch/elasticsearch.go index 105174108e9c..e5176a763520 100644 --- a/pkg/tui/sources/elasticsearch/elasticsearch.go +++ b/pkg/tui/sources/elasticsearch/elasticsearch.go @@ -3,114 +3,98 @@ package elasticsearch import ( "strings" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type elasticSearchCmdModel struct { - textinputs.Model -} - -func GetNote() string { - return "To connect to a local cluster, please provide the node IPs and either (username AND password) OR service token. ⭐\n⭐ To connect to a cloud cluster, please provide cloud ID AND API key." -} - -func GetFields() elasticSearchCmdModel { - return elasticSearchCmdModel{textinputs.New([]textinputs.InputConfig{ - { - Label: "Elastic node(s)", - Key: "nodes", - Required: false, - Help: "Elastic node IPs - for scanning local clusters. Separate by space if multiple.", - }, - { - Label: "Username", - Key: "username", - Required: false, - Help: "Elasticsearch username. Pairs with password. For scanning local clusters.", - }, - { - Label: "Password", - Key: "password", - Required: false, - Help: "Elasticsearch password. Pairs with username. For scanning local clusters.", - }, - { - Label: "Service Token", - Key: "serviceToken", - Required: false, - Help: "Elastic service token. For scanning local clusters.", - }, - { - Label: "Cloud ID", - Key: "cloudId", - Required: false, - Help: "Elastic cloud ID. Pairs with API key. For scanning cloud clusters.", - }, - { - Label: "API Key", - Key: "apiKey", - Required: false, - Help: "Elastic API key. Pairs with cloud ID. For scanning cloud clusters.", - }})} -} - -func findFirstNonEmptyKey(inputs map[string]textinputs.Input, keys []string) string { - for _, key := range keys { - if val, ok := inputs[key]; ok && val.Value != "" { - return key - } - } - return "" -} - -func getConnectionKeys(inputs map[string]textinputs.Input) []string { +// connectionKeysFor returns the subset of keys that should be emitted based +// on which field the user filled in first. The original textinputs-based +// implementation locked the connection mode on the first non-empty value and +// only emitted keys that belong to that mode, which is what we replicate +// here. +func connectionKeysFor(values map[string]string) []string { keys := []string{"username", "password", "serviceToken", "cloudId", "apiKey"} - key := findFirstNonEmptyKey(inputs, keys) - - keyMap := map[string][]string{ - "username": {"username", "password", "nodes"}, - "password": {"username", "password", "nodes"}, - "serviceToken": {"serviceToken", "nodes"}, - "cloudId": {"cloudId", "apiKey"}, - "apiKey": {"cloudId", "apiKey"}, - } - - if val, ok := keyMap[key]; ok { - return val - } - - return nil -} - -func (m elasticSearchCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "elasticsearch") - inputs := m.GetInputs() - - for _, key := range getConnectionKeys(inputs) { - val, ok := inputs[key] - if !ok || val.Value == "" { + for _, k := range keys { + if strings.TrimSpace(values[k]) == "" { continue } - - if key == "nodes" { - nodes := strings.Fields(val.Value) - for _, node := range nodes { - command = append(command, "--nodes="+node) - } - } else { - command = append(command, "--"+key+"="+val.Value) + switch k { + case "username", "password": + return []string{"username", "password", "nodes"} + case "serviceToken": + return []string{"serviceToken", "nodes"} + case "cloudId", "apiKey": + return []string{"cloudId", "apiKey"} } } - - return strings.Join(command, " ") + return nil } -func (m elasticSearchCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - - summaryKeys := getConnectionKeys(inputs) - return common.SummarizeSource(summaryKeys, inputs, labels) +func init() { sources.Register(Definition()) } + +// Definition returns the elasticsearch source configuration. +func Definition() sources.Definition { + return sources.Definition{ + ID: "elasticsearch", + Title: "Elasticsearch", + Description: "Scan your Elasticsearch cluster or Elastic Cloud instance.", + Tier: sources.TierOSS, + Note: "To connect to a local cluster, please provide the node IPs and either (username AND password) OR service token. ⭐\n⭐ To connect to a cloud cluster, please provide cloud ID AND API key.", + Command: "elasticsearch", + Fields: []form.FieldSpec{ + { + Key: "nodes", + Label: "Elastic node(s)", + Help: "Elastic node IPs - for scanning local clusters. Separate by space if multiple.", + Kind: form.KindText, + }, + { + Key: "username", + Label: "Username", + Help: "Elasticsearch username. Pairs with password. For scanning local clusters.", + Kind: form.KindText, + }, + { + Key: "password", + Label: "Password", + Help: "Elasticsearch password. Pairs with username. For scanning local clusters.", + Kind: form.KindSecret, + }, + { + Key: "serviceToken", + Label: "Service Token", + Help: "Elastic service token. For scanning local clusters.", + Kind: form.KindSecret, + }, + { + Key: "cloudId", + Label: "Cloud ID", + Help: "Elastic cloud ID. Pairs with API key. For scanning cloud clusters.", + Kind: form.KindText, + }, + { + Key: "apiKey", + Label: "API Key", + Help: "Elastic API key. Pairs with cloud ID. For scanning cloud clusters.", + Kind: form.KindSecret, + }, + }, + BuildArgs: func(values map[string]string) []string { + var out []string + for _, key := range connectionKeysFor(values) { + val := strings.TrimSpace(values[key]) + if val == "" { + continue + } + if key == "nodes" { + for _, node := range strings.Fields(val) { + out = append(out, "--nodes="+node) + } + continue + } + out = append(out, "--"+key+"="+val) + } + return out + }, + } } diff --git a/pkg/tui/sources/filesystem/filesystem.go b/pkg/tui/sources/filesystem/filesystem.go index e501518f23ab..d7078dd96943 100644 --- a/pkg/tui/sources/filesystem/filesystem.go +++ b/pkg/tui/sources/filesystem/filesystem.go @@ -1,42 +1,30 @@ package filesystem import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type fsModel struct { - textinputs.Model -} - -func GetFields() fsModel { - path := textinputs.InputConfig{ - Label: "Path", - Key: "path", - Required: true, - Help: "Files and directories to scan. Separate by space if multiple.", - Placeholder: "path/to/file.txt path/to/another/dir", +func init() { sources.Register(Definition()) } + +// Definition returns the filesystem source configuration. +func Definition() sources.Definition { + return sources.Definition{ + ID: "filesystem", + Title: "Filesystem", + Description: "Scan your filesystem by selecting what directories to scan.", + Tier: sources.TierOSS, + Command: "filesystem", + Fields: []form.FieldSpec{ + { + Key: "path", + Label: "Path", + Help: "Files and directories to scan. Separate by space if multiple.", + Kind: form.KindText, + Placeholder: "path/to/file.txt path/to/another/dir", + Emit: form.EmitPositional, + Validators: []form.Validate{form.Required()}, + }, + }, } - - return fsModel{textinputs.New([]textinputs.InputConfig{path})} -} - -func (m fsModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "filesystem") - - inputs := m.GetInputs() - command = append(command, inputs["path"].Value) - - return strings.Join(command, " ") -} - -func (m fsModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - - keys := []string{"path"} - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/gcs/gcs.go b/pkg/tui/sources/gcs/gcs.go index fa78510d12af..5e7064e83ef0 100644 --- a/pkg/tui/sources/gcs/gcs.go +++ b/pkg/tui/sources/gcs/gcs.go @@ -1,43 +1,31 @@ package gcs import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type gcsCmdModel struct { - textinputs.Model -} - -func GetFields() gcsCmdModel { - projectId := textinputs.InputConfig{ - Label: "Project ID", - Key: "project-id", - Required: true, - Placeholder: "trufflehog-testing", +func init() { sources.Register(Definition()) } + +// Definition returns the gcs source configuration. +func Definition() sources.Definition { + return sources.Definition{ + ID: "gcs", + Title: "GCS (Google Cloud Storage)", + Description: "Scan a Google Cloud Storage instance.", + Tier: sources.TierOSS, + Command: "gcs", + Fields: []form.FieldSpec{ + { + Key: "project-id", + Label: "Project ID", + Kind: form.KindText, + Placeholder: "trufflehog-testing", + Emit: form.EmitLongFlagEq, + Validators: []form.Validate{form.Required()}, + }, + }, + // Always scan the default cloud environment; matches prior behavior. + ExtraArgs: []string{"--cloud-environment"}, } - - return gcsCmdModel{textinputs.New([]textinputs.InputConfig{projectId})} -} - -func (m gcsCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "gcs") - - inputs := m.GetInputs() - - command = append(command, "--project-id="+inputs["project-id"].Value) - - command = append(command, "--cloud-environment") - return strings.Join(command, " ") -} - -func (m gcsCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - - keys := []string{"project-id"} - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/git/git.go b/pkg/tui/sources/git/git.go index 54e137461672..c1a6af04e379 100644 --- a/pkg/tui/sources/git/git.go +++ b/pkg/tui/sources/git/git.go @@ -1,43 +1,30 @@ package git import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type gitCmdModel struct { - textinputs.Model -} - -func GetFields() gitCmdModel { - uri := textinputs.InputConfig{ - Label: "Git URI", - Key: "uri", - Help: "file:// for local git repos", - Required: true, - Placeholder: "git@github.com:trufflesecurity/trufflehog.git", +func init() { sources.Register(Definition()) } + +// Definition returns the git source configuration. +func Definition() sources.Definition { + return sources.Definition{ + ID: "git", + Title: "Git", + Description: "Scan git repositories.", + Tier: sources.TierOSS, + Command: "git", + Fields: []form.FieldSpec{ + { + Key: "uri", + Label: "Git URI", + Help: "file:// for local git repos", + Kind: form.KindText, + Placeholder: "git@github.com:trufflesecurity/trufflehog.git", + Emit: form.EmitPositional, + Validators: []form.Validate{form.Required()}, + }, + }, } - - return gitCmdModel{textinputs.New([]textinputs.InputConfig{uri})} -} - -func (m gitCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "git") - - inputs := m.GetInputs() - - command = append(command, inputs["uri"].Value) - - return strings.Join(command, " ") -} - -func (m gitCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - - keys := []string{"uri"} - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/github/github.go b/pkg/tui/sources/github/github.go index 13b841ddea39..fb99305185e4 100644 --- a/pkg/tui/sources/github/github.go +++ b/pkg/tui/sources/github/github.go @@ -1,75 +1,43 @@ package github import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type githubCmdModel struct { - textinputs.Model -} - -func GetNote() string { - return "Please enter an organization OR repository." -} - -func GetFields() githubCmdModel { - org := textinputs.InputConfig{ - Label: "Organization", - Key: "org", - Required: true, - Help: "GitHub organization to scan.", - Placeholder: "trufflesecurity", - } - - repo := textinputs.InputConfig{ - Label: "Repository", - Key: "repo", - Required: true, - Help: "GitHub repo to scan.", - Placeholder: "https://github.com/trufflesecurity/test_keys", - } - - return githubCmdModel{textinputs.New([]textinputs.InputConfig{org, repo})} -} - -// Handle default values since GitHub flags are OR operations -func (m githubCmdModel) GetSpecialInputs() map[string]textinputs.Input { - inputs := m.GetInputs() - if inputs["org"].IsDefault != inputs["repo"].IsDefault { - if inputs["org"].IsDefault { - delete(inputs, "org") - } - if inputs["repo"].IsDefault { - delete(inputs, "repo") - } - } - - return inputs -} - -func (m githubCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "github") - inputs := m.GetSpecialInputs() - - if inputs["org"].Value != "" { - command = append(command, "--org="+inputs["org"].Value) +func init() { sources.Register(Definition()) } + +// Definition returns the github source configuration. +func Definition() sources.Definition { + fields := []form.FieldSpec{ + { + Key: "org", + Label: "Organization", + Help: "GitHub organization to scan.", + Kind: form.KindText, + Placeholder: "trufflesecurity", + Emit: form.EmitLongFlagEq, + Group: "target", + }, + { + Key: "repo", + Label: "Repository", + Help: "GitHub repo to scan.", + Kind: form.KindText, + Placeholder: "https://github.com/trufflesecurity/test_keys", + Emit: form.EmitLongFlagEq, + Group: "target", + }, } - if inputs["repo"].Value != "" { - command = append(command, "--repo="+inputs["repo"].Value) + return sources.Definition{ + ID: "github", + Title: "GitHub", + Description: "Scan GitHub repositories and/or organizations.", + Tier: sources.TierOSS, + Note: "Please enter an organization OR repository.", + Command: "github", + Fields: fields, + Constraints: []form.Constraint{form.XOrGroup("target", 1, 1, fields)}, } - - return strings.Join(command, " ") -} - -func (m githubCmdModel) Summary() string { - inputs := m.GetSpecialInputs() - labels := m.GetLabels() - - keys := []string{"org", "repo"} - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/gitlab/gitlab.go b/pkg/tui/sources/gitlab/gitlab.go index a35664051b6b..0b3ce2e45bef 100644 --- a/pkg/tui/sources/gitlab/gitlab.go +++ b/pkg/tui/sources/gitlab/gitlab.go @@ -1,43 +1,30 @@ package gitlab import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type gitlabCmdModel struct { - textinputs.Model -} - -func GetFields() gitlabCmdModel { - token := textinputs.InputConfig{ - Label: "GitLab token", - Key: "token", - Required: true, - Help: "Personal access token with read access", - Placeholder: "glpat-", +func init() { sources.Register(Definition()) } + +// Definition returns the gitlab source configuration. +func Definition() sources.Definition { + return sources.Definition{ + ID: "gitlab", + Title: "GitLab", + Description: "Scan GitLab repositories.", + Tier: sources.TierOSS, + Command: "gitlab", + Fields: []form.FieldSpec{ + { + Key: "token", + Label: "GitLab token", + Help: "Personal access token with read access", + Kind: form.KindSecret, + Placeholder: "glpat-", + Emit: form.EmitLongFlagEq, + Validators: []form.Validate{form.Required()}, + }, + }, } - - return gitlabCmdModel{textinputs.New([]textinputs.InputConfig{token})} -} - -func (m gitlabCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "gitlab") - - inputs := m.GetInputs() - - command = append(command, "--token="+inputs["token"].Value) - - return strings.Join(command, " ") -} - -func (m gitlabCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - - keys := []string{"token"} - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/huggingface/huggingface.go b/pkg/tui/sources/huggingface/huggingface.go index bee86206dcc4..4cdc51c3a3fd 100644 --- a/pkg/tui/sources/huggingface/huggingface.go +++ b/pkg/tui/sources/huggingface/huggingface.go @@ -1,77 +1,65 @@ package huggingface import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type huggingFaceCmdModel struct { - textinputs.Model -} - -func GetNote() string { - return "Please enter the organization, user, model, space, or dataset you would like to scan." -} - -func GetFields() huggingFaceCmdModel { - org := textinputs.InputConfig{ - Label: "Organization", - Key: "org", - Required: false, - Help: "Hugging Face organization name. This will scan all models, datasets, and spaces belonging to the organization.", - } - user := textinputs.InputConfig{ - Label: "Username", - Key: "user", - Required: false, - Help: "Hugging Face user. This will scan all models, datasets, and spaces belonging to the user.", - } - model := textinputs.InputConfig{ - Label: "Model", - Key: "model", - Required: false, - Help: "Hugging Face model. Example: org/model_name or user/model_name", - } - space := textinputs.InputConfig{ - Label: "Space", - Key: "space", - Required: false, - Help: "Hugging Face space. Example: org/space_name or user/space_name.", - } - dataset := textinputs.InputConfig{ - Label: "Dataset", - Key: "dataset", - Required: false, - Help: "Hugging Face dataset. Example: org/dataset_name or user/dataset_name.", - } - - return huggingFaceCmdModel{textinputs.New([]textinputs.InputConfig{org, user, model, space, dataset})} -} - -func (m huggingFaceCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "huggingface") - - inputs := m.GetInputs() - keys := []string{"org", "user", "model", "space", "dataset"} +func init() { sources.Register(Definition()) } - for _, key := range keys { - val, ok := inputs[key] - if !ok || val.Value == "" { - continue - } - - command = append(command, "--"+key+"="+val.Value) +// Definition returns the huggingface source configuration. +// +// All non-empty fields are emitted; the user may fill multiple of +// {org, user, model, space, dataset}. +func Definition() sources.Definition { + return sources.Definition{ + ID: "huggingface", + Title: "Hugging Face", + Description: "Scan Hugging Face, an AI/ML community.", + Tier: sources.TierOSS, + Note: "Please enter the organization, user, model, space, or dataset you would like to scan.", + Command: "huggingface", + Fields: []form.FieldSpec{ + { + Key: "org", + Label: "Organization", + Help: "Hugging Face organization name. This will scan all models, datasets, and spaces belonging to the organization.", + Kind: form.KindText, + Emit: form.EmitLongFlagEq, + Group: "target", + }, + { + Key: "user", + Label: "Username", + Help: "Hugging Face user. This will scan all models, datasets, and spaces belonging to the user.", + Kind: form.KindText, + Emit: form.EmitLongFlagEq, + Group: "target", + }, + { + Key: "model", + Label: "Model", + Help: "Hugging Face model. Example: org/model_name or user/model_name", + Kind: form.KindText, + Emit: form.EmitLongFlagEq, + Group: "target", + }, + { + Key: "space", + Label: "Space", + Help: "Hugging Face space. Example: org/space_name or user/space_name.", + Kind: form.KindText, + Emit: form.EmitLongFlagEq, + Group: "target", + }, + { + Key: "dataset", + Label: "Dataset", + Help: "Hugging Face dataset. Example: org/dataset_name or user/dataset_name.", + Kind: form.KindText, + Emit: form.EmitLongFlagEq, + Group: "target", + }, + }, } - - return strings.Join(command, " ") -} - -func (m huggingFaceCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - keys := []string{"org", "user", "model", "space", "dataset"} - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/jenkins/jenkins.go b/pkg/tui/sources/jenkins/jenkins.go index 59886fbe61a7..0f58e692f149 100644 --- a/pkg/tui/sources/jenkins/jenkins.go +++ b/pkg/tui/sources/jenkins/jenkins.go @@ -3,77 +3,60 @@ package jenkins import ( "strings" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type jenkinsCmdModel struct { - textinputs.Model -} - -func GetNote() string { - return "If no username and password are provided, TruffleHog will attempt an unauthenticated Jenkins scan." -} - -func GetFields() jenkinsCmdModel { - return jenkinsCmdModel{textinputs.New([]textinputs.InputConfig{ - { - Label: "Endpoint URL", - Key: "url", - Required: true, - Help: "URL of the Jenkins server.", - Placeholder: "https://jenkins.example.com", +func init() { sources.Register(Definition()) } + +// Definition returns the jenkins source configuration. +// +// Username + password are emitted only when both are set; either alone is +// dropped so we don't accidentally half-authenticate against a server that +// would then fall back to anonymous auth silently. +func Definition() sources.Definition { + return sources.Definition{ + ID: "jenkins", + Title: "Jenkins", + Description: "Scan Jenkins, a CI/CD platform. (Recently open-sourced from enterprise!)", + Tier: sources.TierOSS, + Note: "If no username and password are provided, TruffleHog will attempt an unauthenticated Jenkins scan.", + Command: "jenkins", + Fields: []form.FieldSpec{ + { + Key: "url", + Label: "Endpoint URL", + Help: "URL of the Jenkins server.", + Kind: form.KindText, + Placeholder: "https://jenkins.example.com", + Validators: []form.Validate{form.Required()}, + }, + { + Key: "username", + Label: "Username", + Help: "For authenticated scans - pairs with password.", + Kind: form.KindText, + }, + { + Key: "password", + Label: "Password", + Help: "For authenticated scans - pairs with username.", + Kind: form.KindSecret, + }, }, - { - Label: "Username", - Key: "username", - Required: false, - Help: "For authenticated scans - pairs with password.", + BuildArgs: func(values map[string]string) []string { + url := strings.TrimSpace(values["url"]) + username := strings.TrimSpace(values["username"]) + password := strings.TrimSpace(values["password"]) + + var out []string + if url != "" { + out = append(out, "--url="+url) + } + if username != "" && password != "" { + out = append(out, "--username="+username, "--password="+password) + } + return out }, - { - Label: "Password", - Key: "password", - Required: false, - Help: "For authenticated scans - pairs with username.", - }})} -} - -func checkIsAuthenticated(inputs map[string]textinputs.Input) bool { - username := inputs["username"].Value - password := inputs["password"].Value - - return username != "" && password != "" -} - -func (m jenkinsCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "jenkins") - inputs := m.GetInputs() - - keys := []string{"url"} - if checkIsAuthenticated(inputs) { - keys = append(keys, "username", "password") - } - - for _, key := range keys { - val, ok := inputs[key] - if !ok || val.Value == "" { - continue - } - command = append(command, "--"+key+"="+val.Value) } - - return strings.Join(command, " ") -} - -func (m jenkinsCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - - summaryKeys := []string{"url"} - if checkIsAuthenticated(inputs) { - summaryKeys = append(summaryKeys, "username", "password") - } - - return common.SummarizeSource(summaryKeys, inputs, labels) } diff --git a/pkg/tui/sources/postman/postman.go b/pkg/tui/sources/postman/postman.go index 7b0451f86e46..e5056f446e51 100644 --- a/pkg/tui/sources/postman/postman.go +++ b/pkg/tui/sources/postman/postman.go @@ -3,81 +3,66 @@ package postman import ( "strings" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type postmanCmdModel struct { - textinputs.Model -} - -func GetNote() string { - return "Please enter an ID for a workspace, collection, or environment." -} - -func GetFields() postmanCmdModel { - token := textinputs.InputConfig{ - Label: "Postman token", - Key: "token", - Required: true, - Help: "Postman API key", - Placeholder: "PMAK-", - } - workspace := textinputs.InputConfig{ - Label: "Workspace ID", - Key: "workspace", - Required: false, - Help: "ID for workspace", - } - collection := textinputs.InputConfig{ - Label: "Collection ID", - Key: "collection", - Required: false, - Help: "ID for an API collection", - } - environment := textinputs.InputConfig{ - Label: "Environment ID", - Key: "environment", - Required: false, - Help: "ID for an environment", - } - - return postmanCmdModel{textinputs.New([]textinputs.InputConfig{token, workspace, collection, environment})} -} - -func findFirstNonEmptyKey(inputs map[string]textinputs.Input, keys []string) string { - for _, key := range keys { - if val, ok := inputs[key]; ok && val.Value != "" { - return key - } - } - return "" -} - -func (m postmanCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "postman") - - inputs := m.GetInputs() - keys := []string{"workspace", "collection", "environment"} - - command = append(command, "--token="+inputs["token"].Value) - key := findFirstNonEmptyKey(inputs, keys) - if key != "" { - command = append(command, "--"+key+"="+inputs[key].Value) - } - return strings.Join(command, " ") -} - -func (m postmanCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - keys := []string{"token", "workspace", "collection", "environment"} +func init() { sources.Register(Definition()) } - summaryKeys := []string{"token"} - key := findFirstNonEmptyKey(inputs, keys[1:]) - if key != "" { - summaryKeys = append(summaryKeys, key) +// Definition returns the postman source configuration. +// +// The token is always emitted; the first non-empty of +// {workspace, collection, environment} is added too, matching the original +// behavior. +func Definition() sources.Definition { + return sources.Definition{ + ID: "postman", + Title: "Postman", + Description: "Scan a collection, workspace, or environment from Postman, the API platform.", + Tier: sources.TierOSS, + Note: "Please enter an ID for a workspace, collection, or environment.", + Command: "postman", + Fields: []form.FieldSpec{ + { + Key: "token", + Label: "Postman token", + Help: "Postman API key", + Kind: form.KindSecret, + Placeholder: "PMAK-", + Validators: []form.Validate{form.Required()}, + }, + { + Key: "workspace", + Label: "Workspace ID", + Help: "ID for workspace", + Kind: form.KindText, + }, + { + Key: "collection", + Label: "Collection ID", + Help: "ID for an API collection", + Kind: form.KindText, + }, + { + Key: "environment", + Label: "Environment ID", + Help: "ID for an environment", + Kind: form.KindText, + }, + }, + BuildArgs: func(values map[string]string) []string { + token := strings.TrimSpace(values["token"]) + var out []string + if token != "" { + out = append(out, "--token="+token) + } + for _, key := range []string{"workspace", "collection", "environment"} { + if v := strings.TrimSpace(values[key]); v != "" { + out = append(out, "--"+key+"="+v) + break + } + } + return out + }, } - return common.SummarizeSource(summaryKeys, inputs, labels) } diff --git a/pkg/tui/sources/registry.go b/pkg/tui/sources/registry.go new file mode 100644 index 000000000000..a9dc550c0827 --- /dev/null +++ b/pkg/tui/sources/registry.go @@ -0,0 +1,95 @@ +package sources + +import ( + "fmt" + "sort" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" +) + +// Tier distinguishes OSS sources (usable directly) from Enterprise sources +// (advertised but gated behind a contact-sales page). +type Tier int + +const ( + // TierOSS is a source shipped in the open-source build. The TUI can + // build a full arg vector and hand it off to kingpin. + TierOSS Tier = iota + // TierEnterprise is an advertised-only source; selecting it surfaces + // the enterprise contact link rather than a form. + TierEnterprise +) + +// Definition is a single source's declarative description. +// +// It replaces the two parallel switches in sources.go (GetSourceFields / +// GetSourceNotes) with an id-keyed registry, and replaces the stringly-typed +// CmdModel.Cmd() pattern with form.FieldSpec + Form.Args(). +type Definition struct { + // ID is the stable, kebab-case identifier (e.g. "github", "gcs"). + ID string + // Title is the human-readable label shown in the source picker. + Title string + // Description is the one-line sub-label shown in the source picker. + Description string + // Tier controls whether the source opens a config form or the + // enterprise contact card. + Tier Tier + // Note is an optional informational banner displayed above the form. + Note string + // Command is the kingpin subcommand (e.g. "github", "filesystem"). + // Empty for enterprise sources. + Command string + // Fields describe the per-source inputs. + Fields []form.FieldSpec + // Constraints are cross-field validators evaluated on submit (e.g. an + // XOr group between mutually-exclusive target selectors). + Constraints []form.Constraint + // ExtraArgs are appended verbatim to the emitted arg vector after the + // form's fields. Used by sources that need a constant flag (e.g. gcs + // always appending --cloud-environment). + ExtraArgs []string + // BuildArgs, when non-nil, replaces the default form.BuildArgs result + // for this source. Provided for the handful of sources whose + // arg-emission logic is genuinely not declarative (e.g. elasticsearch's + // mutex-of-modes, jenkins's auth gating). + BuildArgs func(values map[string]string) []string +} + +// registry is the process-wide source registry. Entries are added at init +// time by each source package. +var registry = map[string]Definition{} + +// Register adds a Definition to the process-wide registry. Registering the +// same ID twice panics — this is intentional so accidental duplicates fail +// loudly at startup rather than silently shadowing one another. +func Register(def Definition) { + if def.ID == "" { + panic("sources: Definition.ID cannot be empty") + } + if _, ok := registry[def.ID]; ok { + panic(fmt.Sprintf("sources: duplicate Definition %q", def.ID)) + } + registry[def.ID] = def +} + +// Get looks up a Definition by ID. The second return is false if no source +// is registered under that ID. +func Get(id string) (Definition, bool) { + d, ok := registry[id] + return d, ok +} + +// All returns every registered Definition, alphabetically by Title. The +// result is a copy; mutating it does not affect the registry. +func All() []Definition { + out := make([]Definition, 0, len(registry)) + for _, d := range registry { + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Title < out[j].Title + }) + return out +} + diff --git a/pkg/tui/sources/s3/s3.go b/pkg/tui/sources/s3/s3.go index dadb43784631..913ff309ad44 100644 --- a/pkg/tui/sources/s3/s3.go +++ b/pkg/tui/sources/s3/s3.go @@ -1,48 +1,33 @@ package s3 import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type s3CmdModel struct { - textinputs.Model -} - -func GetFields() s3CmdModel { - bucket := textinputs.InputConfig{ - Label: "S3 bucket name(s)", - Key: "buckets", - Required: true, - Placeholder: "truffletestbucket", - Help: "Buckets to scan. Separate by space if multiple.", - } - - return s3CmdModel{textinputs.New([]textinputs.InputConfig{bucket})} -} - -func (m s3CmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "s3") - - inputs := m.GetInputs() - vals := inputs["buckets"].Value - if vals != "" { - buckets := strings.Fields(vals) - for _, bucket := range buckets { - command = append(command, "--bucket="+bucket) - } +func init() { sources.Register(Definition()) } + +// Definition returns the s3 source configuration. +// +// Multiple bucket names entered as whitespace-separated values each emit a +// distinct --bucket= token. +func Definition() sources.Definition { + return sources.Definition{ + ID: "s3", + Title: "AWS S3", + Description: "Scan Amazon S3 buckets.", + Tier: sources.TierOSS, + Command: "s3", + Fields: []form.FieldSpec{ + { + Key: "bucket", + Label: "S3 bucket name(s)", + Help: "Buckets to scan. Separate by space if multiple.", + Kind: form.KindText, + Placeholder: "truffletestbucket", + Emit: form.EmitRepeatedLongFlagEq, + Validators: []form.Validate{form.Required()}, + }, + }, } - - return strings.Join(command, " ") -} - -func (m s3CmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - - keys := []string{"buckets"} - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/sources/sources.go b/pkg/tui/sources/sources.go deleted file mode 100644 index 47166c43cfa8..000000000000 --- a/pkg/tui/sources/sources.go +++ /dev/null @@ -1,80 +0,0 @@ -package sources - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/circleci" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/docker" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/elasticsearch" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/filesystem" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/gcs" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/git" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/github" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/gitlab" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/huggingface" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/jenkins" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/postman" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/s3" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/syslog" -) - -func GetSourceNotes(sourceName string) string { - source := strings.ToLower(sourceName) - switch source { - case "github": - return github.GetNote() - case "postman": - return postman.GetNote() - case "elasticsearch": - return elasticsearch.GetNote() - case "huggingface": - return huggingface.GetNote() - case "jenkins": - return jenkins.GetNote() - - default: - return "" - } -} - -type CmdModel interface { - tea.Model - Cmd() string - Summary() string -} - -func GetSourceFields(sourceName string) CmdModel { - source := strings.ToLower(sourceName) - - switch source { - case "aws s3": - return s3.GetFields() - case "circleci": - return circleci.GetFields() - case "docker": - return docker.GetFields() - case "elasticsearch": - return elasticsearch.GetFields() - case "filesystem": - return filesystem.GetFields() - case "gcs (google cloud storage)": - return gcs.GetFields() - case "git": - return git.GetFields() - case "github": - return github.GetFields() - case "gitlab": - return gitlab.GetFields() - case "hugging face": - return huggingface.GetFields() - case "jenkins": - return jenkins.GetFields() - case "postman": - return postman.GetFields() - case "syslog": - return syslog.GetFields() - } - - return nil -} diff --git a/pkg/tui/sources/sources_test.go b/pkg/tui/sources/sources_test.go new file mode 100644 index 000000000000..62d78273ec8c --- /dev/null +++ b/pkg/tui/sources/sources_test.go @@ -0,0 +1,201 @@ +package sources_test + +import ( + "reflect" + "testing" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/circleci" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/docker" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/elasticsearch" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/filesystem" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/gcs" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/git" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/github" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/gitlab" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/huggingface" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/jenkins" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/postman" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/s3" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/syslog" +) + +// TestRegistryComplete asserts every source the overhaul is expected to +// register actually registered. If this fails it's almost always because a +// source package wasn't blank-imported by pkg/tui/tui.go. +func TestRegistryComplete(t *testing.T) { + want := []string{ + "circleci", "docker", "elasticsearch", "filesystem", + "gcs", "git", "github", "gitlab", + "huggingface", "jenkins", "postman", "s3", "syslog", + } + for _, id := range want { + if _, ok := sources.Get(id); !ok { + t.Errorf("source %q not registered", id) + } + } +} + +func TestFormAdapterArgs(t *testing.T) { + tests := []struct { + name string + id string + values map[string]string + want []string + }{ + { + name: "git positional", + id: "git", + values: map[string]string{"uri": "file:///tmp/my repo"}, + want: []string{"git", "file:///tmp/my repo"}, + }, + { + name: "github org", + id: "github", + values: map[string]string{ + "org": "trufflesecurity", + }, + want: []string{"github", "--org=trufflesecurity"}, + }, + { + name: "filesystem positional with spaces", + id: "filesystem", + values: map[string]string{"path": "/tmp/path with spaces"}, + want: []string{"filesystem", "/tmp/path with spaces"}, + }, + { + name: "circleci token", + id: "circleci", + values: map[string]string{"token": "abc123"}, + want: []string{"circleci", "--token=abc123"}, + }, + { + name: "gitlab token", + id: "gitlab", + values: map[string]string{"token": "glpat-xxx"}, + want: []string{"gitlab", "--token=glpat-xxx"}, + }, + { + name: "gcs appends cloud-environment", + id: "gcs", + values: map[string]string{"project-id": "truffle-testing"}, + want: []string{"gcs", "--project-id=truffle-testing", "--cloud-environment"}, + }, + { + name: "docker splits images", + id: "docker", + values: map[string]string{"image": "trufflesecurity/secrets trufflesecurity/other"}, + want: []string{"docker", "--image=trufflesecurity/secrets", "--image=trufflesecurity/other"}, + }, + { + name: "s3 splits buckets", + id: "s3", + values: map[string]string{"bucket": "a b c"}, + want: []string{"s3", "--bucket=a", "--bucket=b", "--bucket=c"}, + }, + { + name: "syslog", + id: "syslog", + values: map[string]string{ + "address": "127.0.0.1:514", + "protocol": "tcp", + "cert": "/etc/cert", + "key": "/etc/key", + "format": "rfc3164", + }, + want: []string{ + "syslog", + "--address=127.0.0.1:514", + "--protocol=tcp", + "--cert=/etc/cert", + "--key=/etc/key", + "--format=rfc3164", + }, + }, + { + name: "huggingface model only", + id: "huggingface", + values: map[string]string{"model": "org/my-model"}, + want: []string{"huggingface", "--model=org/my-model"}, + }, + { + name: "elasticsearch local user/pass", + id: "elasticsearch", + values: map[string]string{ + "username": "u", + "password": "p", + "nodes": "n1 n2", + }, + want: []string{"elasticsearch", "--username=u", "--password=p", "--nodes=n1", "--nodes=n2"}, + }, + { + name: "elasticsearch cloud", + id: "elasticsearch", + values: map[string]string{ + "cloudId": "cid", + "apiKey": "akey", + }, + want: []string{"elasticsearch", "--cloudId=cid", "--apiKey=akey"}, + }, + { + name: "jenkins unauthenticated", + id: "jenkins", + values: map[string]string{ + "url": "https://ci.example.com", + }, + want: []string{"jenkins", "--url=https://ci.example.com"}, + }, + { + name: "jenkins authenticated", + id: "jenkins", + values: map[string]string{ + "url": "https://ci.example.com", + "username": "u", + "password": "p", + }, + want: []string{"jenkins", "--url=https://ci.example.com", "--username=u", "--password=p"}, + }, + { + name: "jenkins half-auth drops credentials", + id: "jenkins", + values: map[string]string{ + "url": "https://ci.example.com", + "username": "u", + }, + want: []string{"jenkins", "--url=https://ci.example.com"}, + }, + { + name: "postman token + first non-empty target", + id: "postman", + values: map[string]string{ + "token": "PMAK-foo", + "workspace": "", + "collection": "col-1", + "environment": "env-1", + }, + want: []string{"postman", "--token=PMAK-foo", "--collection=col-1"}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + def, ok := sources.Get(tc.id) + if !ok { + t.Fatalf("source %q not registered", tc.id) + } + adapter := sources.NewFormAdapter(def) + for k, v := range tc.values { + for _, f := range adapter.Form().Fields() { + if f.Spec().Key == k { + f.SetValue(v) + } + } + } + got := adapter.Cmd() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("args mismatch\nwant: %#v\ngot: %#v", tc.want, got) + } + }) + } +} diff --git a/pkg/tui/sources/syslog/syslog.go b/pkg/tui/sources/syslog/syslog.go index 26a1b0d96774..7dae7c120e6b 100644 --- a/pkg/tui/sources/syslog/syslog.go +++ b/pkg/tui/sources/syslog/syslog.go @@ -1,80 +1,74 @@ package syslog import ( - "strings" - - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/textinputs" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/form" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources" ) -type syslogCmdModel struct { - textinputs.Model -} - -// TODO: review fields -func GetFields() syslogCmdModel { - protocol := textinputs.InputConfig{ - Label: "Protocol", - Key: "protocol", - Required: true, - Help: "udp or tcp", - Placeholder: "tcp", - } - - listenAddress := textinputs.InputConfig{ - Label: "Address", - Key: "address", - Help: "Address and port to listen on for syslog", - Required: true, - Placeholder: "127.0.0.1:514", - } - - tlsCert := textinputs.InputConfig{ - Label: "TLS Certificate", - Key: "cert", - Required: true, - Help: "Path to TLS certificate", - Placeholder: "/path/to/cert", - } - - tlsKey := textinputs.InputConfig{ - Label: "TLS Key", - Key: "key", - Required: true, - Help: "Path to TLS key", - Placeholder: "/path/to/key", - } - - format := textinputs.InputConfig{ - Label: "Log format", - Key: "format", - Required: true, - Help: "Can be rfc3164 or rfc5424", - Placeholder: "rfc3164", - } - - return syslogCmdModel{textinputs.New([]textinputs.InputConfig{listenAddress, protocol, tlsCert, tlsKey, format})} -} - -func (m syslogCmdModel) Cmd() string { - var command []string - command = append(command, "trufflehog", "syslog") +func init() { sources.Register(Definition()) } - inputs := m.GetInputs() - syslogKeys := [5]string{"address", "protocol", "cert", "key", "format"} - - for _, key := range syslogKeys { - flag := "--" + key + "=" + inputs[key].Value - command = append(command, flag) +// Definition returns the syslog source configuration. +func Definition() sources.Definition { + return sources.Definition{ + ID: "syslog", + Title: "Syslog", + Description: "Scan syslog, event data logs.", + Tier: sources.TierOSS, + Command: "syslog", + Fields: []form.FieldSpec{ + { + Key: "address", + Label: "Address", + Help: "Address and port to listen on for syslog", + Kind: form.KindText, + Placeholder: "127.0.0.1:514", + Emit: form.EmitLongFlagEq, + Validators: []form.Validate{form.Required()}, + }, + { + Key: "protocol", + Label: "Protocol", + Help: "udp or tcp", + Kind: form.KindSelect, + Emit: form.EmitLongFlagEq, + Default: "tcp", + Options: []form.SelectOption{ + {Label: "TCP", Value: "tcp"}, + {Label: "UDP", Value: "udp"}, + }, + Validators: []form.Validate{form.Required(), form.OneOf("tcp", "udp")}, + }, + { + Key: "cert", + Label: "TLS Certificate", + Help: "Path to TLS certificate", + Kind: form.KindText, + Placeholder: "/path/to/cert", + Emit: form.EmitLongFlagEq, + Validators: []form.Validate{form.Required()}, + }, + { + Key: "key", + Label: "TLS Key", + Help: "Path to TLS key", + Kind: form.KindText, + Placeholder: "/path/to/key", + Emit: form.EmitLongFlagEq, + Validators: []form.Validate{form.Required()}, + }, + { + Key: "format", + Label: "Log format", + Help: "Can be rfc3164 or rfc5424", + Kind: form.KindSelect, + Emit: form.EmitLongFlagEq, + Default: "rfc3164", + Options: []form.SelectOption{ + {Label: "rfc3164", Value: "rfc3164"}, + {Label: "rfc5424", Value: "rfc5424"}, + }, + Validators: []form.Validate{form.Required(), form.OneOf("rfc3164", "rfc5424")}, + }, + }, } - - return strings.Join(command, " ") -} - -func (m syslogCmdModel) Summary() string { - inputs := m.GetInputs() - labels := m.GetLabels() - keys := []string{"address", "protocol", "cert", "key", "format"} - - return common.SummarizeSource(keys, inputs, labels) } diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go deleted file mode 100644 index 0182f18e6bbc..000000000000 --- a/pkg/tui/styles/styles.go +++ /dev/null @@ -1,493 +0,0 @@ -package styles - -import ( - "github.com/charmbracelet/lipgloss" -) - -// XXX: For now, this is in its own package so that it can be shared between -// different packages without incurring an illegal import cycle. - -// https://github.com/charmbracelet/lipgloss#colors -var Colors = map[string]string{ - "softblack": "#1e1e1e", - "charcoal": "#252525", - "stone": "#5a5a5a", - "smoke": "#999999", - "sand": "#e1deda", - "cloud": "#f4efe9", - "offwhite": "#faf8f7", - "fern": "#38645a", - "sprout": "#5bb381", - "gold": "#ae8c57", - "bronze": "#89553d", - "coral": "#c15750", - "violet": "#6b5b9a", -} - -var ( - BoldTextStyle = lipgloss.NewStyle().Bold(true) - - PrimaryTextStyle = lipgloss.NewStyle().Foreground( - lipgloss.Color("28")) // green - - HintTextStyle = lipgloss.NewStyle().Foreground( - lipgloss.Color("8")) // grey - - CodeTextStyle = lipgloss.NewStyle().Background(lipgloss.Color("130")).Foreground(lipgloss.Color("15")) -) - -var AppStyle = lipgloss.NewStyle().Padding(1, 2) - -// Styles defines styles for the UI. -type Styles struct { - ActiveBorderColor lipgloss.Color - InactiveBorderColor lipgloss.Color - - App lipgloss.Style - ServerName lipgloss.Style - TopLevelNormalTab lipgloss.Style - TopLevelActiveTab lipgloss.Style - TopLevelActiveTabDot lipgloss.Style - - MenuItem lipgloss.Style - MenuLastUpdate lipgloss.Style - - RepoSelector struct { - Normal struct { - Base lipgloss.Style - Title lipgloss.Style - Desc lipgloss.Style - Command lipgloss.Style - Updated lipgloss.Style - } - Active struct { - Base lipgloss.Style - Title lipgloss.Style - Desc lipgloss.Style - Command lipgloss.Style - Updated lipgloss.Style - } - } - - Repo struct { - Base lipgloss.Style - Title lipgloss.Style - Command lipgloss.Style - Body lipgloss.Style - Header lipgloss.Style - HeaderName lipgloss.Style - HeaderDesc lipgloss.Style - } - - Footer lipgloss.Style - Branch lipgloss.Style - HelpKey lipgloss.Style - HelpValue lipgloss.Style - HelpDivider lipgloss.Style - URLStyle lipgloss.Style - - Error lipgloss.Style - ErrorTitle lipgloss.Style - ErrorBody lipgloss.Style - - AboutNoReadme lipgloss.Style - - LogItem struct { - Normal struct { - Base lipgloss.Style - Hash lipgloss.Style - Title lipgloss.Style - Desc lipgloss.Style - Keyword lipgloss.Style - } - Active struct { - Base lipgloss.Style - Hash lipgloss.Style - Title lipgloss.Style - Desc lipgloss.Style - Keyword lipgloss.Style - } - } - - Log struct { - Commit lipgloss.Style - CommitHash lipgloss.Style - CommitAuthor lipgloss.Style - CommitDate lipgloss.Style - CommitBody lipgloss.Style - CommitStatsAdd lipgloss.Style - CommitStatsDel lipgloss.Style - Paginator lipgloss.Style - } - - Ref struct { - Normal struct { - Item lipgloss.Style - ItemTag lipgloss.Style - } - Active struct { - Item lipgloss.Style - ItemTag lipgloss.Style - } - ItemSelector lipgloss.Style - ItemBranch lipgloss.Style - Paginator lipgloss.Style - } - - Tree struct { - Normal struct { - FileName lipgloss.Style - FileDir lipgloss.Style - FileMode lipgloss.Style - FileSize lipgloss.Style - } - Active struct { - FileName lipgloss.Style - FileDir lipgloss.Style - FileMode lipgloss.Style - FileSize lipgloss.Style - } - Selector lipgloss.Style - FileContent lipgloss.Style - Paginator lipgloss.Style - NoItems lipgloss.Style - } - - Spinner lipgloss.Style - - CodeNoContent lipgloss.Style - - StatusBar lipgloss.Style - StatusBarKey lipgloss.Style - StatusBarValue lipgloss.Style - StatusBarInfo lipgloss.Style - StatusBarBranch lipgloss.Style - StatusBarHelp lipgloss.Style - - Tabs lipgloss.Style - TabInactive lipgloss.Style - TabActive lipgloss.Style - TabSeparator lipgloss.Style -} - -// DefaultStyles returns default styles for the UI. -func DefaultStyles() *Styles { - highlightColor := lipgloss.Color("210") - highlightColorDim := lipgloss.Color("174") - selectorColor := lipgloss.Color("167") - hashColor := lipgloss.Color("185") - - s := new(Styles) - - s.ActiveBorderColor = lipgloss.Color("62") - s.InactiveBorderColor = lipgloss.Color("241") - - s.App = lipgloss.NewStyle(). - Margin(1, 2) - - s.ServerName = lipgloss.NewStyle(). - Height(1). - MarginLeft(1). - MarginBottom(1). - Padding(0, 1). - Background(lipgloss.Color("57")). - Foreground(lipgloss.Color("229")). - Bold(true) - - s.TopLevelNormalTab = lipgloss.NewStyle(). - MarginRight(2) - - s.TopLevelActiveTab = s.TopLevelNormalTab. - Foreground(lipgloss.Color("36")) - - s.TopLevelActiveTabDot = lipgloss.NewStyle(). - Foreground(lipgloss.Color("36")) - - s.RepoSelector.Normal.Base = lipgloss.NewStyle(). - PaddingLeft(1). - Border(lipgloss.Border{Left: " "}, false, false, false, true). - Height(3) - - s.RepoSelector.Normal.Title = lipgloss.NewStyle().Bold(true) - - s.RepoSelector.Normal.Desc = lipgloss.NewStyle(). - Foreground(lipgloss.Color("243")) - - s.RepoSelector.Normal.Command = lipgloss.NewStyle(). - Foreground(lipgloss.Color("132")) - - s.RepoSelector.Normal.Updated = lipgloss.NewStyle(). - Foreground(lipgloss.Color("243")) - - s.RepoSelector.Active.Base = s.RepoSelector.Normal.Base. - BorderStyle(lipgloss.Border{Left: "┃"}). - BorderForeground(lipgloss.Color("176")) - - s.RepoSelector.Active.Title = s.RepoSelector.Normal.Title. - Foreground(lipgloss.Color("212")) - - s.RepoSelector.Active.Desc = s.RepoSelector.Normal.Desc. - Foreground(lipgloss.Color("246")) - - s.RepoSelector.Active.Updated = s.RepoSelector.Normal.Updated. - Foreground(lipgloss.Color("212")) - - s.RepoSelector.Active.Command = s.RepoSelector.Normal.Command. - Foreground(lipgloss.Color("204")) - - s.MenuItem = lipgloss.NewStyle(). - PaddingLeft(1). - Border(lipgloss.Border{ - Left: " ", - }, false, false, false, true). - Height(3) - - s.MenuLastUpdate = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - Align(lipgloss.Right) - - s.Repo.Base = lipgloss.NewStyle() - - s.Repo.Title = lipgloss.NewStyle(). - Padding(0, 2) - - s.Repo.Command = lipgloss.NewStyle(). - Foreground(lipgloss.Color("168")) - - s.Repo.Body = lipgloss.NewStyle(). - Margin(1, 0) - - s.Repo.Header = lipgloss.NewStyle(). - Height(2). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(lipgloss.Color("236")) - - s.Repo.HeaderName = lipgloss.NewStyle(). - Foreground(lipgloss.Color("212")). - Bold(true) - - s.Repo.HeaderDesc = lipgloss.NewStyle(). - Foreground(lipgloss.Color("243")) - - s.Footer = lipgloss.NewStyle(). - MarginTop(1). - Padding(0, 1). - Height(1) - - s.Branch = lipgloss.NewStyle(). - Foreground(lipgloss.Color("203")). - Background(lipgloss.Color("236")). - Padding(0, 1) - - s.HelpKey = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) - - s.HelpValue = lipgloss.NewStyle(). - Foreground(lipgloss.Color("239")) - - s.HelpDivider = lipgloss.NewStyle(). - Foreground(lipgloss.Color("237")). - SetString(" • ") - - s.URLStyle = lipgloss.NewStyle(). - MarginLeft(1). - Foreground(lipgloss.Color("168")) - - s.Error = lipgloss.NewStyle(). - MarginTop(2) - - s.ErrorTitle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("230")). - Background(lipgloss.Color("204")). - Bold(true). - Padding(0, 1) - - s.ErrorBody = lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")). - MarginLeft(2) - - s.AboutNoReadme = lipgloss.NewStyle(). - MarginTop(1). - MarginLeft(2). - Foreground(lipgloss.Color("242")) - - s.LogItem.Normal.Base = lipgloss.NewStyle(). - Border(lipgloss.Border{ - Left: " ", - }, false, false, false, true). - PaddingLeft(1) - - s.LogItem.Active.Base = s.LogItem.Normal.Base. - Border(lipgloss.Border{ - Left: "┃", - }, false, false, false, true). - BorderForeground(selectorColor) - - s.LogItem.Active.Hash = s.LogItem.Normal.Hash. - Foreground(hashColor) - - s.LogItem.Active.Hash = lipgloss.NewStyle(). - Bold(true). - Foreground(highlightColor) - - s.LogItem.Normal.Title = lipgloss.NewStyle(). - Foreground(lipgloss.Color("105")) - - s.LogItem.Active.Title = lipgloss.NewStyle(). - Foreground(highlightColor). - Bold(true) - - s.LogItem.Normal.Desc = lipgloss.NewStyle(). - Foreground(lipgloss.Color("246")) - - s.LogItem.Active.Desc = lipgloss.NewStyle(). - Foreground(lipgloss.Color("95")) - - s.LogItem.Active.Keyword = s.LogItem.Active.Desc. - Foreground(highlightColorDim) - - s.LogItem.Normal.Hash = lipgloss.NewStyle(). - Foreground(hashColor) - - s.LogItem.Active.Hash = lipgloss.NewStyle(). - Foreground(highlightColor) - - s.Log.Commit = lipgloss.NewStyle(). - Margin(0, 2) - - s.Log.CommitHash = lipgloss.NewStyle(). - Foreground(hashColor). - Bold(true) - - s.Log.CommitBody = lipgloss.NewStyle(). - MarginTop(1). - MarginLeft(2) - - s.Log.CommitStatsAdd = lipgloss.NewStyle(). - Foreground(lipgloss.Color("42")). - Bold(true) - - s.Log.CommitStatsDel = lipgloss.NewStyle(). - Foreground(lipgloss.Color("203")). - Bold(true) - - s.Log.Paginator = lipgloss.NewStyle(). - Margin(0). - Align(lipgloss.Center) - - s.Ref.Normal.Item = lipgloss.NewStyle() - - s.Ref.ItemSelector = lipgloss.NewStyle(). - Foreground(selectorColor). - SetString("> ") - - s.Ref.Active.Item = lipgloss.NewStyle(). - Foreground(highlightColorDim) - - s.Ref.ItemBranch = lipgloss.NewStyle() - - s.Ref.Normal.ItemTag = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")) - - s.Ref.Active.ItemTag = lipgloss.NewStyle(). - Bold(true). - Foreground(highlightColor) - - s.Ref.Active.Item = lipgloss.NewStyle(). - Bold(true). - Foreground(highlightColor) - - s.Ref.Paginator = s.Log.Paginator - - s.Tree.Selector = s.Tree.Normal.FileName. - Width(1). - Foreground(selectorColor) - - s.Tree.Normal.FileName = lipgloss.NewStyle(). - MarginLeft(1) - - s.Tree.Active.FileName = s.Tree.Normal.FileName. - Bold(true). - Foreground(highlightColor) - - s.Tree.Normal.FileDir = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")) - - s.Tree.Active.FileDir = lipgloss.NewStyle(). - Foreground(highlightColor) - - s.Tree.Normal.FileMode = s.Tree.Active.FileName. - Width(10). - Foreground(lipgloss.Color("243")) - - s.Tree.Active.FileMode = s.Tree.Normal.FileMode. - Foreground(highlightColorDim) - - s.Tree.Normal.FileSize = s.Tree.Normal.FileName. - Foreground(lipgloss.Color("243")) - - s.Tree.Active.FileSize = s.Tree.Normal.FileName. - Foreground(highlightColorDim) - - s.Tree.FileContent = lipgloss.NewStyle() - - s.Tree.Paginator = s.Log.Paginator - - s.Tree.NoItems = s.AboutNoReadme - - s.Spinner = lipgloss.NewStyle(). - MarginTop(1). - MarginLeft(2). - Foreground(lipgloss.Color("205")) - - s.CodeNoContent = lipgloss.NewStyle(). - SetString("No Content."). - MarginTop(1). - MarginLeft(2). - Foreground(lipgloss.Color("242")) - - s.StatusBar = lipgloss.NewStyle(). - Height(1) - - s.StatusBarKey = lipgloss.NewStyle(). - Bold(true). - Padding(0, 1). - Background(lipgloss.Color("206")). - Foreground(lipgloss.Color("228")) - - s.StatusBarValue = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("235")). - Foreground(lipgloss.Color("243")) - - s.StatusBarInfo = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("212")). - Foreground(lipgloss.Color("230")) - - s.StatusBarBranch = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("62")). - Foreground(lipgloss.Color("230")) - - s.StatusBarHelp = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("237")). - Foreground(lipgloss.Color("243")) - - s.Tabs = lipgloss.NewStyle(). - Height(1) - - s.TabInactive = lipgloss.NewStyle() - - s.TabActive = lipgloss.NewStyle(). - Underline(true). - Foreground(lipgloss.Color("36")) - - s.TabSeparator = lipgloss.NewStyle(). - SetString("│"). - Padding(0, 1). - Foreground(lipgloss.Color("238")) - - return s -} diff --git a/pkg/tui/theme/colors.go b/pkg/tui/theme/colors.go new file mode 100644 index 000000000000..3b47424263d5 --- /dev/null +++ b/pkg/tui/theme/colors.go @@ -0,0 +1,42 @@ +// Package theme centralizes colors, styles and key bindings for the TUI. +// +// All UI code should source colors and styles from this package so that the +// visual language stays consistent across pages and the set of literal color +// codes in the tree is kept to zero. +package theme + +import "github.com/charmbracelet/lipgloss" + +// Named palette. These are the only colors the TUI is allowed to use; the +// names follow the TruffleHog marketing palette. +var ( + Softblack = lipgloss.Color("#1e1e1e") + Charcoal = lipgloss.Color("#252525") + Stone = lipgloss.Color("#5a5a5a") + Smoke = lipgloss.Color("#999999") + Sand = lipgloss.Color("#e1deda") + Cloud = lipgloss.Color("#f4efe9") + Offwhite = lipgloss.Color("#faf8f7") + Fern = lipgloss.Color("#38645a") + Sprout = lipgloss.Color("#5bb381") + Gold = lipgloss.Color("#ae8c57") + Bronze = lipgloss.Color("#89553d") + Coral = lipgloss.Color("#c15750") + Violet = lipgloss.Color("#6b5b9a") +) + +// Semantic roles. Pages should reach for these rather than the named palette +// so that a palette change at the top of the file propagates everywhere. +var ( + ColorPrimary = Sprout + ColorAccent = Bronze + ColorHint = Smoke + ColorMuted = Stone + ColorError = Coral + ColorLink = Violet + ColorCodeBG = Bronze + ColorCodeFG = Offwhite + ColorSelectBG = Bronze + ColorSelectFG = Offwhite + ColorTabActive = Fern +) diff --git a/pkg/tui/theme/keymap.go b/pkg/tui/theme/keymap.go new file mode 100644 index 000000000000..88b48b7de118 --- /dev/null +++ b/pkg/tui/theme/keymap.go @@ -0,0 +1,65 @@ +package theme + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap is the set of bindings the TUI listens for. Only bindings actually +// observed somewhere in the tree live here; adding a binding that nothing +// matches is a signal to delete it rather than keep it around. +type KeyMap struct { + // Quit closes the TUI at any depth via ctrl+c. + Quit key.Binding + // CmdQuit closes the TUI from a root page via `q`. + CmdQuit key.Binding + // Back pops the current page; exits if the stack is empty. + Back key.Binding + // Select activates the focused item (enter). + Select key.Binding + // Section moves between sections / tabs. + Section key.Binding + // UpDown navigates vertically within a list / form. + UpDown key.Binding + // LeftRight navigates horizontally (tabs, radio groups). + LeftRight key.Binding +} + +// DefaultKeyMap returns the default KeyMap. +func DefaultKeyMap() *KeyMap { + km := new(KeyMap) + + km.Quit = key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ) + + km.CmdQuit = key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ) + + km.Back = key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ) + + km.Select = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ) + + km.Section = key.NewBinding( + key.WithKeys("tab", "shift+tab"), + key.WithHelp("tab", "section"), + ) + + km.UpDown = key.NewBinding( + key.WithKeys("up", "down", "k", "j"), + key.WithHelp("↑↓", "navigate"), + ) + + km.LeftRight = key.NewBinding( + key.WithKeys("left", "right", "h", "l"), + key.WithHelp("←→", "navigate"), + ) + + return km +} diff --git a/pkg/tui/theme/styles.go b/pkg/tui/theme/styles.go new file mode 100644 index 000000000000..dd06e0d55a9b --- /dev/null +++ b/pkg/tui/theme/styles.go @@ -0,0 +1,83 @@ +package theme + +import "github.com/charmbracelet/lipgloss" + +// Styles is the style sheet the TUI renders with. Only fields actually +// rendered somewhere live here; if you find yourself adding a field that +// isn't rendered on-screen, delete it instead. +type Styles struct { + // App is applied once at the router level; every page view is rendered + // inside its frame. + App lipgloss.Style + + // Text roles. + Title lipgloss.Style + Hint lipgloss.Style + Primary lipgloss.Style + Bold lipgloss.Style + Code lipgloss.Style + Link lipgloss.Style + Error lipgloss.Style + + // Menu / list items (wizard + source picker). + MenuItem lipgloss.Style + SelectedItem lipgloss.Style + + // Tabs (source-config page). + TabActive lipgloss.Style + TabInactive lipgloss.Style + TabSeparator lipgloss.Style +} + +// DefaultStyles returns the default Styles. +func DefaultStyles() *Styles { + s := new(Styles) + + s.App = lipgloss.NewStyle().Margin(1, 2) + + s.Title = lipgloss.NewStyle(). + Foreground(ColorAccent). + Background(Offwhite). + Bold(true). + Padding(0, 1) + + s.Hint = lipgloss.NewStyle().Foreground(ColorHint) + s.Primary = lipgloss.NewStyle().Foreground(ColorPrimary) + s.Bold = lipgloss.NewStyle().Bold(true) + + s.Code = lipgloss.NewStyle(). + Background(ColorCodeBG). + Foreground(ColorCodeFG) + + s.Link = lipgloss.NewStyle(). + Foreground(ColorLink). + Underline(true) + + s.Error = lipgloss.NewStyle().Foreground(ColorError) + + s.MenuItem = lipgloss.NewStyle(). + PaddingLeft(1). + Border(lipgloss.Border{Left: " "}, false, false, false, true). + Height(3) + + s.SelectedItem = lipgloss.NewStyle(). + PaddingLeft(1). + Border(lipgloss.Border{Left: "┃"}, false, false, false, true). + BorderForeground(ColorAccent). + Foreground(ColorAccent). + Bold(true). + Height(3) + + s.TabActive = lipgloss.NewStyle(). + Underline(true). + Foreground(ColorTabActive) + + s.TabInactive = lipgloss.NewStyle() + + s.TabSeparator = lipgloss.NewStyle(). + SetString("│"). + Padding(0, 1). + Foreground(Smoke) + + return s +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 0faffaf35fe7..9d1c9a1e31e9 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -1,3 +1,6 @@ +// Package tui is the thin runner that boots the Bubble Tea program, wires +// up the router and page factories, and hands the final deliverables back +// to main (argv for kingpin, or an analyzer SecretInfo). package tui import ( @@ -5,270 +8,107 @@ import ( "os" "strings" - "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/analyzer/analyzers" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/components/selector" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/keymap" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/analyze_form" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/analyze_keys" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/contact_enterprise" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/source_configure" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/source_select" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/view_oss" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/wizard_intro" - "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" -) - -type page int - -const ( - wizardIntroPage page = iota - sourceSelectPage - sourceConfigurePage - viewOSSProjectPage - contactEnterprisePage - analyzeKeysPage - analyzeFormPage -) - -type sessionState int - -const ( - startState sessionState = iota - errorState - loadedState + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/app" + analyzerform "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/analyzer-form" + analyzerpicker "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/analyzer-picker" + linkcard "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/link-card" + sourceconfig "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/source-config" + sourcepicker "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/source-picker" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/wizard" + + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/circleci" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/docker" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/elasticsearch" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/filesystem" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/gcs" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/git" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/github" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/gitlab" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/huggingface" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/jenkins" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/postman" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/s3" + _ "github.com/trufflesecurity/trufflehog/v3/pkg/tui/sources/syslog" ) -// TUI is the main TUI model. -type TUI struct { - common common.Common - pages []common.Component - pageHistory []page - state sessionState - args []string - - // Analyzer specific values that are only set if running an analysis. - analyzerType string - analyzerInfo analyzer.SecretInfo -} - -// New returns a new TUI model. -func New(c common.Common, args []string) *TUI { - ui := &TUI{ - common: c, - pages: make([]common.Component, 7), - pageHistory: []page{wizardIntroPage}, - state: startState, - args: args, - } - switch { - case len(args) == 0: - return ui - case len(args) == 1 && args[0] == "analyze": - ui.pageHistory = []page{wizardIntroPage, analyzeKeysPage} - } - return ui -} - -// SetSize implements common.Component. -func (ui *TUI) SetSize(width, height int) { - ui.common.SetSize(width, height) - for _, p := range ui.pages { - if p != nil { - p.SetSize(width, height) - } - } -} - -// Init implements tea.Model. -func (ui *TUI) Init() tea.Cmd { - - ui.pages[wizardIntroPage] = wizard_intro.New(ui.common) - ui.pages[sourceSelectPage] = source_select.New(ui.common) - ui.pages[sourceConfigurePage] = source_configure.New(ui.common) - ui.pages[viewOSSProjectPage] = view_oss.New(ui.common) - ui.pages[contactEnterprisePage] = contact_enterprise.New(ui.common) - ui.pages[analyzeKeysPage] = analyze_keys.New(ui.common) - - if len(ui.args) > 1 && ui.args[0] == "analyze" { - analyzerArg := strings.ToLower(ui.args[1]) - ui.pages[analyzeFormPage] = analyze_form.New(ui.common, analyzerArg) - ui.setActivePage(analyzeKeysPage) - - for _, analyzer := range analyzers.AvailableAnalyzers() { - if strings.ToLower(analyzer) == analyzerArg { - ui.setActivePage(analyzeFormPage) - } - } - } else { - ui.pages[analyzeFormPage] = analyze_form.New(ui.common, "this is a bug") - } - - ui.SetSize(ui.common.Width, ui.common.Height) - cmds := make([]tea.Cmd, 0) - cmds = append(cmds, - ui.pages[wizardIntroPage].Init(), - ui.pages[sourceSelectPage].Init(), - ui.pages[sourceConfigurePage].Init(), - ui.pages[viewOSSProjectPage].Init(), - ui.pages[contactEnterprisePage].Init(), - ui.pages[analyzeKeysPage].Init(), - ui.pages[analyzeFormPage].Init(), - ) - ui.state = loadedState - ui.SetSize(ui.common.Width, ui.common.Height) - return tea.Batch(cmds...) -} - -// Update implements tea.Model. -func (ui *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.WindowSizeMsg: - ui.SetSize(msg.Width, msg.Height) - for i, p := range ui.pages { - m, cmd := p.Update(msg) - ui.pages[i] = m.(common.Component) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - case tea.KeyMsg, tea.MouseMsg: - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, ui.common.KeyMap.Help): - case key.Matches(msg, ui.common.KeyMap.CmdQuit) && - (ui.activePage() == wizardIntroPage || ui.activePage() == analyzeKeysPage || ui.activePage() == sourceSelectPage): - - ui.args = nil - return ui, tea.Quit - case key.Matches(msg, ui.common.KeyMap.Quit): - ui.args = nil - return ui, tea.Quit - case key.Matches(msg, ui.common.KeyMap.Back): - if ui.activePage() == wizardIntroPage { - ui.args = nil - return ui, tea.Quit - } - _ = ui.popHistory() - return ui, nil - } - } - case common.ErrorMsg: - return ui, nil - case selector.SelectMsg: - switch item := msg.IdentifiableItem.(type) { - case wizard_intro.Item: - - switch item { - case wizard_intro.Quit: - ui.args = nil - cmds = append(cmds, tea.Quit) - case wizard_intro.ViewOSSProject: - ui.setActivePage(viewOSSProjectPage) - case wizard_intro.ViewHelpDocs: - ui.args = []string{"--help"} - return ui, tea.Quit - case wizard_intro.EnterpriseInquire: - ui.setActivePage(contactEnterprisePage) - case wizard_intro.ScanSourceWithWizard: - ui.setActivePage(sourceSelectPage) - case wizard_intro.AnalyzeSecret: - ui.setActivePage(analyzeKeysPage) - } - case source_select.SourceItem: - ui.setActivePage(sourceConfigurePage) - cmds = append(cmds, func() tea.Msg { - return source_configure.SetSourceMsg{Source: item.ID()} - }) - case analyze_keys.KeyTypeItem: - ui.setActivePage(analyzeFormPage) - cmds = append(cmds, func() tea.Msg { - return analyze_form.SetAnalyzerMsg(item.ID()) - }) - } - case source_configure.SetArgsMsg: - ui.args = strings.Split(string(msg), " ")[1:] - return ui, tea.Quit - case analyze_form.Submission: - ui.analyzerType = msg.AnalyzerType - ui.analyzerInfo = msg.AnalyzerInfo - return ui, tea.Quit - } - - if ui.state == loadedState { - m, cmd := ui.pages[ui.activePage()].Update(msg) - ui.pages[ui.activePage()] = m.(common.Component) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - // This fixes determining the height margin of the footer. - // ui.SetSize(ui.common.Width, ui.common.Height) - return ui, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (ui *TUI) View() string { - var view string - switch ui.state { - case startState: - view = "Loading..." - case loadedState: - view = ui.pages[ui.activePage()].View() - default: - view = "Unknown state :/ this is a bug!" - } - return ui.common.Zone.Scan( - ui.common.Styles.App.Render(view), - ) -} - +// Run launches the TUI and returns either the argv to hand to kingpin (when +// the user completed a scan flow) or calls os.Exit directly (when the user +// quit or completed an analyzer flow). +// +// The kingpin contract is preserved: a nil-args / empty-args return on +// user-cancel, a sliced arg vector otherwise, and analyzer.Run dispatched +// in-process when the user picked that flow. func Run(args []string) []string { - c := common.Common{ - Copy: nil, - Styles: styles.DefaultStyles(), - KeyMap: keymap.DefaultKeyMap(), - Width: 0, - Height: 0, - Zone: zone.New(), - } - m := New(c, args) + m := app.New() + registerPages(m) + m.SetInitialPage(initialPage(args)) + p := tea.NewProgram(m) - // TODO: Print normal help message. if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) + fmt.Printf("Alas, there's been an error: %v\n", err) os.Exit(1) } - if m.analyzerType != "" { - analyzer.Run(m.analyzerType, m.analyzerInfo) + + if t := m.AnalyzerType(); t != "" { + analyzer.Run(t, m.AnalyzerInfo()) os.Exit(0) } - return m.args + return m.Args() } -func (ui *TUI) activePage() page { - if len(ui.pageHistory) == 0 { - return wizardIntroPage - } - return ui.pageHistory[len(ui.pageHistory)-1] +// registerPages wires every page factory into the router. Factories accept +// the arbitrary Data payload that the caller PushMsg's with. +func registerPages(m *app.Model) { + styles := m.Styles() + keymap := m.Keymap() + + m.Register(app.PageWizard, func(data any) app.Page { + return wizard.New(styles, keymap, data) + }) + m.Register(app.PageSourcePicker, func(data any) app.Page { + return sourcepicker.New(styles, keymap, data) + }) + m.Register(app.PageSourceConfig, func(data any) app.Page { + return sourceconfig.New(styles, keymap, data) + }) + m.Register(app.PageAnalyzerPicker, func(data any) app.Page { + return analyzerpicker.New(styles, keymap, data) + }) + m.Register(app.PageAnalyzerForm, func(data any) app.Page { + return analyzerform.New(styles, keymap, data) + }) + m.Register(app.PageLinkCard, func(data any) app.Page { + return linkcard.New(styles, data) + }) } -func (ui *TUI) popHistory() page { - if len(ui.pageHistory) == 0 { - return wizardIntroPage +// initialPage decides which page to open first based on the argv the parent +// main.go hands us. +// +// Preserves the previous behavior: +// - no args → wizard +// - "analyze" → analyzer-picker +// - "analyze " → analyzer-form for that type +// - "analyze " → analyzer-picker (user picks) +func initialPage(args []string) (app.PageID, any) { + if len(args) == 0 { + return app.PageWizard, nil } - p := ui.activePage() - ui.pageHistory = ui.pageHistory[:len(ui.pageHistory)-1] - return p -} - -func (ui *TUI) setActivePage(p page) { - ui.pageHistory = append(ui.pageHistory, p) + if len(args) >= 1 && args[0] == "analyze" { + if len(args) == 1 { + return app.PageAnalyzerPicker, nil + } + wanted := strings.ToLower(args[1]) + for _, a := range analyzers.AvailableAnalyzers() { + if strings.ToLower(a) == wanted { + return app.PageAnalyzerForm, analyzerform.Data{KeyType: wanted} + } + } + return app.PageAnalyzerPicker, nil + } + return app.PageWizard, nil } diff --git a/pkg/tui/tui_test.go b/pkg/tui/tui_test.go new file mode 100644 index 000000000000..ff7366a86092 --- /dev/null +++ b/pkg/tui/tui_test.go @@ -0,0 +1,43 @@ +package tui + +import ( + "testing" + + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/app" + analyzerform "github.com/trufflesecurity/trufflehog/v3/pkg/tui/pages/analyzer-form" +) + +func TestInitialPage(t *testing.T) { + tests := []struct { + name string + args []string + wantID app.PageID + wantKey string // analyzer-form KeyType when data is analyzerform.Data + }{ + {name: "no args → wizard", args: nil, wantID: app.PageWizard}, + {name: "empty args → wizard", args: []string{}, wantID: app.PageWizard}, + {name: "analyze only → picker", args: []string{"analyze"}, wantID: app.PageAnalyzerPicker}, + {name: "analyze unknown → picker", args: []string{"analyze", "bogus-analyzer"}, wantID: app.PageAnalyzerPicker}, + {name: "analyze github → form", args: []string{"analyze", "github"}, wantID: app.PageAnalyzerForm, wantKey: "github"}, + {name: "analyze GitHub casefold → form", args: []string{"analyze", "GitHub"}, wantID: app.PageAnalyzerForm, wantKey: "github"}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + id, data := initialPage(tc.args) + if id != tc.wantID { + t.Fatalf("id: got %q, want %q", id, tc.wantID) + } + if tc.wantKey == "" { + return + } + d, ok := data.(analyzerform.Data) + if !ok { + t.Fatalf("data: got %T, want analyzerform.Data", data) + } + if d.KeyType != tc.wantKey { + t.Errorf("KeyType: got %q, want %q", d.KeyType, tc.wantKey) + } + }) + } +}