From 59fccbcf3f83943aa2bf82e204e8976a7b546ed3 Mon Sep 17 00:00:00 2001 From: Miccah Date: Mon, 5 Aug 2024 15:00:46 -0700 Subject: [PATCH] Analyze TUI (#3172) * Setup TUI entrypoint * Setup key type selector and form pages * Add basic confirmation component * Add basic list selector for analyzer type * Add form page * Remove quit confirmation * Add styles * Add input text redaction * Add log file input to form * Fix some bugs and race conditions * Remove unused code * Fix filtering bug --- go.sum | 6 - main.go | 51 +--- pkg/analyzer/analyzers/analyzers.go | 26 ++ pkg/analyzer/cli.go | 267 +++++--------------- pkg/analyzer/tui/form.go | 100 ++++++++ pkg/analyzer/tui/keytype.go | 88 +++++++ pkg/analyzer/tui/tui.go | 114 +++++++++ pkg/tui/components/confirm/confirm.go | 115 +++++++++ pkg/tui/components/textinputs/textinputs.go | 52 +++- pkg/tui/keymap/keymap.go | 24 ++ 10 files changed, 571 insertions(+), 272 deletions(-) create mode 100644 pkg/analyzer/tui/form.go create mode 100644 pkg/analyzer/tui/keytype.go create mode 100644 pkg/analyzer/tui/tui.go create mode 100644 pkg/tui/components/confirm/confirm.go diff --git a/go.sum b/go.sum index 72ae6de15..7ddfcb0c0 100644 --- a/go.sum +++ b/go.sum @@ -708,8 +708,6 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 h1:34icjjmqJ2HPjrSuJYEkdZ+0ItmGQAQ75cRHIiftIyE= -github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/tailscale/depaware v0.0.0-20240804103531-585336c3e1b3 h1:1wRadBXZ4ddC71FYpzDTS3weuj0IOgmeptZtgTDGU7g= github.com/tailscale/depaware v0.0.0-20240804103531-585336c3e1b3/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/testcontainers/testcontainers-go v0.32.0 h1:ug1aK08L3gCHdhknlTTwWjPHPS+/alvLJU/DRxTD/ME= @@ -914,8 +912,6 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -927,8 +923,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/main.go b/main.go index 0f4a7f5e7..183ef961f 100644 --- a/main.go +++ b/main.go @@ -37,57 +37,8 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/version" ) -// usageTemplate is a copy of kingpin.DefaultUsageTemplate with a minor change -// to not list all flattened commands. This is required to hide all of the -// analyze sub-commands from the main help. -const usageTemplate = `{{define "FormatCommand" -}} -{{if .FlagSummary}} {{.FlagSummary}}{{end -}} -{{range .Args}}{{if not .Hidden}} {{if not .Required}}[{{end}}{{if .PlaceHolder}}{{.PlaceHolder}}{{else}}<{{.Name}}>{{end}}{{if .Value|IsCumulative}}...{{end}}{{if not .Required}}]{{end}}{{end}}{{end -}} -{{end -}} - -{{define "FormatCommands" -}} -{{range .Commands -}} -{{if not .Hidden -}} - {{.FullCommand}}{{if .Default}}*{{end}}{{template "FormatCommand" .}} -{{.Help|Wrap 4}} -{{end -}} -{{end -}} -{{end -}} - -{{define "FormatUsage" -}} -{{template "FormatCommand" .}}{{if .Commands}} [ ...]{{end}} -{{if .Help}} -{{.Help|Wrap 0 -}} -{{end -}} - -{{end -}} - -{{if .Context.SelectedCommand -}} -usage: {{.App.Name}} {{.Context.SelectedCommand}}{{template "FormatUsage" .Context.SelectedCommand}} -{{ else -}} -usage: {{.App.Name}}{{template "FormatUsage" .App}} -{{end}} -{{if .Context.Flags -}} -Flags: -{{.Context.Flags|FlagsToTwoColumns|FormatTwoColumns}} -{{end -}} -{{if .Context.Args -}} -Args: -{{.Context.Args|ArgsToTwoColumns|FormatTwoColumns}} -{{end -}} -{{if .Context.SelectedCommand -}} -{{if len .Context.SelectedCommand.Commands -}} -Subcommands: -{{template "FormatCommands" .Context.SelectedCommand}} -{{end -}} -{{else if .App.Commands -}} -Commands: -{{template "FormatCommands" .App}} -{{end -}} -` - var ( - cli = kingpin.New("TruffleHog", "TruffleHog is a tool for finding credentials.").UsageTemplate(usageTemplate) + cli = kingpin.New("TruffleHog", "TruffleHog is a tool for finding credentials.") cmd string debug = cli.Flag("debug", "Run in debug mode.").Bool() trace = cli.Flag("trace", "Run in trace mode.").Bool() diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 971f64b2f..d33c0185b 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -60,6 +60,32 @@ const ( FullAccess string = "full_access" ) +// Sorted list of all available analyzers. Used for valid sub-commands and TUI +// selection. TODO: Change slice type to Analyzer interface when all available +// analyzers implement it. +var AvailableAnalyzers = []string{ + "airbrake", + "asana", + "bitbucket", + "github", + "gitlab", + "huggingface", + "mailchimp", + "mailgun", + "mysql", + "openai", + "opsgenie", + "postgres", + "postman", + "sendgrid", + "shopify", + "slack", + "sourcegraph", + "square", + "stripe", + "twilio", +} + type PermissionStatus struct { Value bool IsError bool diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go index 36d87bc76..28374d108 100644 --- a/pkg/analyzer/cli.go +++ b/pkg/analyzer/cli.go @@ -1,6 +1,9 @@ package analyzer import ( + "fmt" + "strings" + "github.com/alecthomas/kingpin/v2" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airbrake" @@ -24,227 +27,75 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/stripe" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/twilio" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/tui" ) var ( // TODO: Add list of supported key types. - list *kingpin.CmdClause - showAll *bool - log *bool - - githubScan *kingpin.CmdClause - githubKey *string - - sendgridScan *kingpin.CmdClause - sendgridKey *string - - openAIScan *kingpin.CmdClause - openaiKey *string - - postgresScan *kingpin.CmdClause - postgresConnectionStr *string - - mysqlScan *kingpin.CmdClause - mysqlConnectionStr *string - - // mongodbScan *kingpin.CmdClause - // mongodbConnectionStr *string - - slackScan *kingpin.CmdClause - slackKey *string - - twilioScan *kingpin.CmdClause - twilioKey *string - - airbrakeScan *kingpin.CmdClause - airbrakeKey *string - - huggingfaceScan *kingpin.CmdClause - huggingfaceKey *string - - stripeScan *kingpin.CmdClause - stripeKey *string - - gitlabScan *kingpin.CmdClause - gitlabKey *string - - mailchimpScan *kingpin.CmdClause - mailchimpKey *string - - // mandrillScan *kingpin.CmdClause - // mandrillKey *string - - postmanScan *kingpin.CmdClause - postmanKey *string - - bitbucketScan *kingpin.CmdClause - bitbucketKey *string - - asanaScan *kingpin.CmdClause - asanaKey *string - - mailgunScan *kingpin.CmdClause - mailgunKey *string - - squareScan *kingpin.CmdClause - squareKey *string - - sourcegraphScan *kingpin.CmdClause - sourcegraphKey *string - - shopifyScan *kingpin.CmdClause - shopifyKey *string - shopifyStoreURL *string - - opsgenieScan *kingpin.CmdClause - opsgenieKey *string + analyzeKeyType *string ) func Command(app *kingpin.Application) *kingpin.CmdClause { - // TODO: Add list of supported key types. - cli := app.Command("analyze", "Analyze API keys for fine-grained permissions information").Hidden() - list = cli.Command("list", "List supported API providers") - showAll = cli.Flag("show-all", "Show all data, including permissions not available to this account + publicly-available data related to this account.").Default("false").Bool() - log = cli.Flag("log", "Log all HTTP requests sent during analysis to a file").Default("false").Bool() + cli := app.Command("analyze", "Analyze API keys for fine-grained permissions information.").Hidden() - githubScan = cli.Command("github", "Scan a GitHub API key") - githubKey = githubScan.Arg("key", "GitHub Key.").Required().String() - - sendgridScan = cli.Command("sendgrid", "Scan a Sendgrid API key") - sendgridKey = sendgridScan.Arg("key", "Sendgrid Key.").Required().String() - - openAIScan = cli.Command("openai", "Scan an OpenAI API key") - openaiKey = openAIScan.Arg("key", "OpenAI Key.").Required().String() - - postgresScan = cli.Command("postgres", "Scan a Postgres connection string") - postgresConnectionStr = postgresScan.Arg("connection-string", "Postgres Connection String. As a reference, here's an example: postgresql://[user[:password]@][netloc][:port][/dbname][?param1=value1&...]").Required().String() - - mysqlScan = cli.Command("mysql", "Scan a MySQL connection string") - mysqlConnectionStr = mysqlScan.Arg("connection-string", "MySQL Connection String. As a reference, here's an example: mysql://[user[:password]@][netloc][:port][/dbname][?param1=value1&...]").Required().String() - - // mongodbScan = cli.Command("mongodb", "Scan a MongoDB connection string") - // mongodbConnectionStr = mongodbScan.Arg("connection-string", "MongoDB Connection String. As a reference, here's an example: mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[defaultauthdb][?options]]").Required().String() - - slackScan = cli.Command("slack", "Scan a Slack API key") - slackKey = slackScan.Arg("key", "Slack Key.").Required().String() - - twilioScan = cli.Command("twilio", "Scan a Twilio API key") - twilioKey = twilioScan.Arg("key", "Twilio API Key SID & Secret (ex: keySID:keySecret).").Required().String() - - airbrakeScan = cli.Command("airbrake", "Scan an Airbrake User Key or Token") - airbrakeKey = airbrakeScan.Arg("key", "Airbrake User Key or Token.").Required().String() - - huggingfaceScan = cli.Command("huggingface", "Scan a Huggingface API key") - huggingfaceKey = huggingfaceScan.Arg("key", "Huggingface Key.").Required().String() - - stripeScan = cli.Command("stripe", "Scan a Stripe API key") - stripeKey = stripeScan.Arg("key", "Stripe Key.").Required().String() - - gitlabScan = cli.Command("gitlab", "Scan a GitLab API key") - gitlabKey = gitlabScan.Arg("key", "GitLab Key.").Required().String() - - mailchimpScan = cli.Command("mailchimp", "Scan a Mailchimp API key") - mailchimpKey = mailchimpScan.Arg("key", "Mailchimp Key.").Required().String() - - // mandrillScan = cli.Command("mandrill", "Scan a Mandrill API key") - // mandrillKey = mandrillScan.Arg("key", "Mandril Key.").Required().String() - - postmanScan = cli.Command("postman", "Scan a Postman API key") - postmanKey = postmanScan.Arg("key", "Postman Key.").Required().String() - - bitbucketScan = cli.Command("bitbucket", "Scan a Bitbucket Access Token") - bitbucketKey = bitbucketScan.Arg("key", "Bitbucket Access Token.").Required().String() - - asanaScan = cli.Command("asana", "Scan an Asana API key") - asanaKey = asanaScan.Arg("key", "Asana Key.").Required().String() - - mailgunScan = cli.Command("mailgun", "Scan a Mailgun API key") - mailgunKey = mailgunScan.Arg("key", "Mailgun Key.").Required().String() - - squareScan = cli.Command("square", "Scan a Square API key") - squareKey = squareScan.Arg("key", "Square Key.").Required().String() - - sourcegraphScan = cli.Command("sourcegraph", "Scan a Sourcegraph Access Token") - sourcegraphKey = sourcegraphScan.Arg("key", "Sourcegraph Access Token.").Required().String() - - shopifyScan = cli.Command("shopify", "Scan a Shopify API key") - shopifyKey = shopifyScan.Arg("key", "Shopify Key.").Required().String() - shopifyStoreURL = shopifyScan.Arg("store-url", "Shopify Store Domain (ex: 22297c-c6.myshopify.com).").Required().String() - - opsgenieScan = cli.Command("opsgenie", "Scan an Opsgenie API key") - opsgenieKey = opsgenieScan.Arg("key", "Opsgenie Key.").Required().String() + keyTypeHelp := fmt.Sprintf( + "Type of key to analyze. Omit to interactively choose. Available key types: %s", + strings.Join(analyzers.AvailableAnalyzers, ", "), + ) + analyzeKeyType = cli.Arg("key-type", keyTypeHelp).Enum(analyzers.AvailableAnalyzers...) return cli } func Run(cmd string) { - // Initialize configuration - cfg := &config.Config{ - LoggingEnabled: *log, - ShowAll: *showAll, + keyType, secretInfo, err := tui.Run(*analyzeKeyType) + if err != nil { + // TODO: Log error. + return } - switch cmd { - case list.FullCommand(): - panic("todo") - case githubScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("github") - github.AnalyzeAndPrintPermissions(cfg, *githubKey) - case sendgridScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("sendgrid") - sendgrid.AnalyzeAndPrintPermissions(cfg, *sendgridKey) - case openAIScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("openai") - openai.AnalyzeAndPrintPermissions(cfg, *openaiKey) - case postgresScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("postgres") - postgres.AnalyzeAndPrintPermissions(cfg, *postgresConnectionStr) - case mysqlScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("mysql") - mysql.AnalyzeAndPrintPermissions(cfg, *mysqlConnectionStr) - case slackScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("slack") - slack.AnalyzeAndPrintPermissions(cfg, *slackKey) - case twilioScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("twilio") - twilio.AnalyzeAndPrintPermissions(cfg, *twilioKey) - case airbrakeScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("airbrake") - airbrake.AnalyzeAndPrintPermissions(cfg, *airbrakeKey) - case huggingfaceScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("huggingface") - huggingface.AnalyzeAndPrintPermissions(cfg, *huggingfaceKey) - case stripeScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("stripe") - stripe.AnalyzeAndPrintPermissions(cfg, *stripeKey) - case gitlabScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("gitlab") - gitlab.AnalyzeAndPrintPermissions(cfg, *gitlabKey) - case mailchimpScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("mailchimp") - mailchimp.AnalyzeAndPrintPermissions(cfg, *mailchimpKey) - case postmanScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("postman") - postman.AnalyzeAndPrintPermissions(cfg, *postmanKey) - case bitbucketScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("bitbucket") - bitbucket.AnalyzeAndPrintPermissions(cfg, *bitbucketKey) - case asanaScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("asana") - asana.AnalyzeAndPrintPermissions(cfg, *asanaKey) - case mailgunScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("mailgun") - mailgun.AnalyzeAndPrintPermissions(cfg, *mailgunKey) - case squareScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("square") - square.AnalyzeAndPrintPermissions(cfg, *squareKey) - case sourcegraphScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("sourcegraph") - sourcegraph.AnalyzeAndPrintPermissions(cfg, *sourcegraphKey) - case shopifyScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("shopify") - shopify.AnalyzeAndPrintPermissions(cfg, *shopifyKey, *shopifyStoreURL) - case opsgenieScan.FullCommand(): - cfg.LogFile = analyzers.CreateLogFileName("opsgenie") - opsgenie.AnalyzeAndPrintPermissions(cfg, *opsgenieKey) + if secretInfo.Cfg == nil { + secretInfo.Cfg = &config.Config{} + } + switch keyType { + case "github": + github.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "sendgrid": + sendgrid.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "openai": + openai.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "postgres": + postgres.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "mysql": + mysql.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "slack": + slack.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "twilio": + twilio.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "airbrake": + airbrake.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "huggingface": + huggingface.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "stripe": + stripe.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "gitlab": + gitlab.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "mailchimp": + mailchimp.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "postman": + postman.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "bitbucket": + bitbucket.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "asana": + asana.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "mailgun": + mailgun.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "square": + square.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "sourcegraph": + sourcegraph.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "shopify": + shopify.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"], secretInfo.Parts["url"]) + case "opsgenie": + opsgenie.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) } } diff --git a/pkg/analyzer/tui/form.go b/pkg/analyzer/tui/form.go new file mode 100644 index 000000000..132d3f8f9 --- /dev/null +++ b/pkg/analyzer/tui/form.go @@ -0,0 +1,100 @@ +package tui + +import ( + "fmt" + "slices" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "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" +) + +type FormPage struct { + Common *common.Common + KeyType string + form textinputs.Model +} + +func NewFormPage(c *common.Common, keyType string) FormPage { + inputs := []textinputs.InputConfig{{ + Label: "Secret", + Key: "key", + Required: true, + RedactInput: true, + }} + if keyType == "shopify" { + inputs = append(inputs, textinputs.InputConfig{ + Label: "Shopify URL", + Key: "url", + Required: true, + }) + } + 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 ⚠️"). + SetSubmitMsg("Run TruffleHog Analyze") + return FormPage{ + Common: c, + KeyType: keyType, + form: form, + } +} + +func (FormPage) Init() tea.Cmd { + return nil +} + +func (ui FormPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // TODO: Check form focus. + if msg, ok := msg.(tea.KeyMsg); ok { + switch { + case key.Matches(msg, ui.Common.KeyMap.Back): + return ui.PrevPage() + } + } + 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 SecretInfo{Cfg: &cfg, Parts: values} + } + return nil, secretInfoCmd + } + form, cmd := ui.form.Update(msg) + ui.form = form.(textinputs.Model) + return ui, cmd +} + +func (ui FormPage) View() string { + return styles.AppStyle.Render(ui.form.View()) +} + +func (ui FormPage) PrevPage() (tea.Model, tea.Cmd) { + page := NewKeyTypePage(ui.Common) + // Select what was previously selected. + index, ok := slices.BinarySearch(analyzers.AvailableAnalyzers, ui.KeyType) + if !ok { + // Should be impossible. + index = 0 + } + page.list.Select(index) + return page, nil +} diff --git a/pkg/analyzer/tui/keytype.go b/pkg/analyzer/tui/keytype.go new file mode 100644 index 000000000..ffe63e0ee --- /dev/null +++ b/pkg/analyzer/tui/keytype.go @@ -0,0 +1,88 @@ +package tui + +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/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 KeyTypePage struct { + Common *common.Common + + list list.Model +} + +func (ui KeyTypePage) Init() tea.Cmd { + return nil +} + +func NewKeyTypePage(c *common.Common) KeyTypePage { + 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 KeyTypePage{ + Common: c, + list: list, + } +} + +func (ui KeyTypePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !ui.list.SettingFilter() { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, ui.Common.KeyMap.Back): + return nil, tea.Quit + case key.Matches(msg, ui.Common.KeyMap.Select): + chosen := string(ui.list.SelectedItem().(KeyTypeItem)) + return NewFormPage(ui.Common, chosen), SetKeyTypeCmd(chosen) + } + } + } + + var cmd tea.Cmd + ui.list, cmd = ui.list.Update(msg) + return ui, cmd +} + +func (ui KeyTypePage) View() string { + return styles.AppStyle.Render(ui.list.View()) +} + +func (ui KeyTypePage) NextPage(keyType string) (tea.Model, tea.Cmd) { + return NewFormPage(ui.Common, keyType), SetKeyTypeCmd(keyType) +} + +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) } diff --git a/pkg/analyzer/tui/tui.go b/pkg/analyzer/tui/tui.go new file mode 100644 index 000000000..2472c6a49 --- /dev/null +++ b/pkg/analyzer/tui/tui.go @@ -0,0 +1,114 @@ +package tui + +import ( + "errors" + "fmt" + "slices" + + tea "github.com/charmbracelet/bubbletea" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/keymap" + "github.com/trufflesecurity/trufflehog/v3/pkg/tui/styles" +) + +// TUI is the main TUI model. +type TUI struct { + keyType string + secretInfo *SecretInfo + common *common.Common + model tea.Model +} + +type SecretInfo struct { + Parts map[string]string + Cfg *config.Config +} + +var AbortError error = errors.New("command aborted") + +func Run(keyType string) (string, *SecretInfo, error) { + // If a keyType is provided, make sure it's in the list of AvailableAnalyzers. + if keyType != "" { + if _, ok := slices.BinarySearch(analyzers.AvailableAnalyzers, keyType); !ok { + return "", nil, fmt.Errorf("Unrecognized command %q", keyType) + } + } + + t := &TUI{ + keyType: keyType, + common: &common.Common{ + KeyMap: keymap.DefaultKeyMap(), + }, + } + if _, err := tea.NewProgram(t).Run(); err != nil { + return "", nil, err + } + if t.secretInfo == nil { + return "", nil, AbortError + } + return t.keyType, t.secretInfo, nil +} + +func (ui *TUI) Init() tea.Cmd { + return nil +} + +func (ui *TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.WindowSizeMsg); ok { + ui.SetSize(msg) + } + // Always be able to force quit. + if msg, ok := msg.(tea.KeyMsg); ok && msg.Type.String() == "ctrl+c" { + return ui, tea.Quit + } + + switch m := msg.(type) { + case SetKeyTypeMsg: + ui.keyType = string(m) + case SecretInfo: + ui.secretInfo = &m + return ui, tea.Quit + } + + if ui.model == nil { + return ui, nil + } + + var cmd tea.Cmd + ui.model, cmd = ui.model.Update(msg) + return ui, cmd +} + +func (ui *TUI) View() string { + if ui.model == nil { + return "Loading..." + } + return ui.model.View() +} + +func (ui *TUI) SetSize(msg tea.WindowSizeMsg) { + h, v := styles.AppStyle.GetFrameSize() + h, v = msg.Width-h, msg.Height-v + ui.common.SetSize(h, v) + if ui.model != nil { + return + } + + // Set the model only after we have size information. + // TODO: Responsive pages. + if ui.keyType == "" { + ui.model = NewKeyTypePage(ui.common) + } else { + ui.model = NewFormPage(ui.common, ui.keyType) + } +} + +type SetKeyTypeMsg string + +func SetKeyTypeCmd(keyType string) tea.Cmd { + return func() tea.Msg { + return SetKeyTypeMsg(keyType) + } +} diff --git a/pkg/tui/components/confirm/confirm.go b/pkg/tui/components/confirm/confirm.go new file mode 100644 index 000000000..0ca406969 --- /dev/null +++ b/pkg/tui/components/confirm/confirm.go @@ -0,0 +1,115 @@ +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/textinputs/textinputs.go b/pkg/tui/components/textinputs/textinputs.go index e60f23085..3b53bd9d1 100644 --- a/pkg/tui/components/textinputs/textinputs.go +++ b/pkg/tui/components/textinputs/textinputs.go @@ -19,8 +19,6 @@ var ( // cursorStyle = focusedStyle.Copy() // cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) - focusedButton = focusedStyle.Copy().Render("[ Next ]") - blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Next")) 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")) ) @@ -37,6 +35,9 @@ type Model struct { configs []InputConfig // cursorMode cursor.Mode skipButton bool + submitMsg string + header string + footer string } type InputConfig struct { @@ -45,6 +46,7 @@ type InputConfig struct { Help string Required bool Placeholder string + RedactInput bool } type Input struct { @@ -80,7 +82,8 @@ func (m Model) GetLabels() map[string]string { func New(config []InputConfig) Model { m := Model{ - inputs: make([]textinput.Model, len(config)), + inputs: make([]textinput.Model, len(config)), + submitMsg: "Next", } for i, conf := range config { @@ -171,6 +174,10 @@ func (m *Model) updateInputs(msg tea.Msg) tea.Cmd { 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 { @@ -184,18 +191,32 @@ func (m Model) View() string { b.WriteString(m.GetLabel(m.configs[i])) } - b.WriteString(m.inputs[i].View()) + 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') } } - button := &blurredButton - if m.focusIndex == len(m.inputs) { - button = &focusedButton + if m.footer != "" { + fmt.Fprintf(&b, "\n\n%s", m.footer) } - fmt.Fprintf(&b, "\n\n%s\n\n", *button) + + 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() } @@ -227,6 +248,21 @@ func (m Model) SetSkip(skip bool) Model { 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 diff --git a/pkg/tui/keymap/keymap.go b/pkg/tui/keymap/keymap.go index bb3cb8c2a..6ef713575 100644 --- a/pkg/tui/keymap/keymap.go +++ b/pkg/tui/keymap/keymap.go @@ -6,6 +6,8 @@ import "github.com/charmbracelet/bubbles/key" type KeyMap struct { Quit key.Binding CmdQuit key.Binding + Left key.Binding + Right key.Binding Up key.Binding Down key.Binding UpDown key.Binding @@ -84,6 +86,28 @@ func DefaultKeyMap() *KeyMap { ), ) + 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",