diff --git a/cli/cmd/cli_state.go b/cli/cmd/cli_state.go index 32972775f..7445bea7d 100644 --- a/cli/cmd/cli_state.go +++ b/cli/cmd/cli_state.go @@ -22,6 +22,7 @@ import ( "fmt" "time" + "github.com/BurntSushi/toml" "github.com/briandowns/spinner" "github.com/fatih/color" prettyjson "github.com/hokaccha/go-prettyjson" @@ -116,6 +117,26 @@ func (c *cliState) LoadState() error { return nil } +// LoadProfiles loads all the profiles from the configuration file +func (c *cliState) LoadProfiles() (Profiles, error) { + var ( + profiles = Profiles{} + confPath = viper.ConfigFileUsed() + ) + + if confPath == "" { + return profiles, errors.New("unable to load profiles. No configuration file found.") + } + + cli.Log.Debugw("decoding config", "path", confPath) + if _, err := toml.DecodeFile(confPath, &profiles); err != nil { + return profiles, errors.Wrap(err, "unable to decode profiles from config") + } + + cli.Log.Debugw("profiles loaded from config", "profiles", profiles) + return profiles, nil +} + // VerifySettings checks if the CLI state has the neccessary settings to run, // if not, it throws an error with breadcrumbs to help the user configure the CLI func (c *cliState) VerifySettings() error { diff --git a/cli/cmd/cli_unix.go b/cli/cmd/cli_unix.go index 0b03b4fb1..115333be1 100644 --- a/cli/cmd/cli_unix.go +++ b/cli/cmd/cli_unix.go @@ -21,6 +21,9 @@ package cmd import "github.com/AlecAivazis/survey/v2" +// used by configure.go +var configureListCmdSetProfileEnv = `$ export LW_PROFILE="my-profile"` + // promptIconsFuncs configures the prompt icons for Unix systems var promptIconsFunc = func(icons *survey.IconSet) { icons.Question.Text = "▸" diff --git a/cli/cmd/cli_windows.go b/cli/cmd/cli_windows.go index 3b4c3d001..05c6814e8 100644 --- a/cli/cmd/cli_windows.go +++ b/cli/cmd/cli_windows.go @@ -20,6 +20,9 @@ package cmd import "github.com/AlecAivazis/survey/v2" +// used by configure.go +var configureListCmdSetProfileEnv = `C:\> $env:LW_PROFILE = 'my-profile'` + // promptIconsFuncs configures the prompt icons for Windows systems var promptIconsFunc = func(icons *survey.IconSet) { icons.Question.Text = ">" diff --git a/cli/cmd/configure.go b/cli/cmd/configure.go index 4b596391e..81e73dbc0 100644 --- a/cli/cmd/configure.go +++ b/cli/cmd/configure.go @@ -23,13 +23,16 @@ import ( "encoding/json" "fmt" "io/ioutil" + "os" "path" "regexp" + "sort" "strings" "github.com/AlecAivazis/survey/v2" "github.com/BurntSushi/toml" homedir "github.com/mitchellh/go-homedir" + "github.com/olekukonko/tablewriter" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -85,8 +88,7 @@ var ( Use: "configure", Short: "configure the Lacework CLI", Args: cobra.NoArgs, - Long: ` -Configure settings that the Lacework CLI uses to interact with the Lacework + Long: `Configure settings that the Lacework CLI uses to interact with the Lacework platform. These include your Lacework account, API access key and secret. To create a set of API keys, log in to your Lacework account via WebUI and @@ -94,24 +96,108 @@ navigate to Settings > API Keys and click + Create New. Enter a name for the key and an optional description, then click Save. To get the secret key, download the generated API key file. -Use the argument --json_file to preload the downloaded API key file. +Use the flag --json_file to preload the downloaded API key file. -If this command is run with no arguments, the Lacework CLI will store all +If this command is run with no flags, the Lacework CLI will store all settings under the default profile. The information in the default profile is used any time you run a Lacework CLI command that doesn't explicitly specify a profile to use. -You can configure multiple profiles by using the --profile argument. If a -config file does not exist (the default location is ~/.lacework.toml), the -Lacework CLI will create it for you.`, +You can configure multiple profiles by using the --profile flag. If a +config file does not exist (the default location is ~/.lacework.toml), +the Lacework CLI will create it for you.`, RunE: func(_ *cobra.Command, _ []string) error { return promptConfigureSetup() }, } + + configureListCmd = &cobra.Command{ + Use: "list", + Short: "list all configured profiles at ~/.lacework.toml", + Args: cobra.NoArgs, + Long: `List all profiles configured into the config file ~/.lacework.toml + +To switch to a different profile permanently in your current terminal, +export the environment variable: + + ` + configureListCmdSetProfileEnv, + RunE: func(_ *cobra.Command, _ []string) error { + profiles, err := cli.LoadProfiles() + if err != nil { + return err + } + + var ( + strBuilder = &strings.Builder{} + table = tablewriter.NewWriter(strBuilder) + ) + + table.SetBorder(false) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetHeader([]string{"Profile", "Account", "API Key", "API Secret"}) + table.AppendBulk(buildProfilesTableContent(cli.Profile, profiles)) + table.Render() + + cli.OutputHuman(strBuilder.String()) + return nil + }, + } + + configureGetCmd = &cobra.Command{ + Use: "show ", + Short: "show current configuration data", + Args: cobra.ExactArgs(1), + Long: `Prints the current computed configuration data from the specified configuration +key. The order of precedence to compute the configuration is flags, environment +variables, and the configuration file ~/.lacework.toml. + +The available configuration keys are: +* profile +* account +* api_secret +* api_key + +To show the configuration from a different profile, use the flag --profile. + + $ lacework configure show account --profile my-profile`, + RunE: func(_ *cobra.Command, args []string) error { + data, ok := showConfigurationDataFromKey(args[0]) + if !ok { + return errors.New("unknown configuration key. (available: profile, account, api_secret, api_key)") + } + + if data == "" { + // @afiune something is not set correctly, here is a big + // exeption to exit the CLI in a non-standard way + os.Exit(1) + } + + cli.OutputHuman(data) + cli.OutputHuman("\n") + return nil + }, + } ) +func showConfigurationDataFromKey(key string) (string, bool) { + switch key { + case "profile": + return cli.Profile, true + case "account": + return cli.Account, true + case "api_secret": + return cli.Secret, true + case "api_key": + return cli.KeyID, true + default: + return "", false + } +} + func init() { rootCmd.AddCommand(configureCmd) + configureCmd.AddCommand(configureListCmd) + configureCmd.AddCommand(configureGetCmd) configureCmd.Flags().StringVarP(&configureJsonFile, "json_file", "j", "", "loads the generated API key JSON file from the WebUI", @@ -226,10 +312,10 @@ func promptConfigureSetup() error { var ( profiles = Profiles{} - buf = new(bytes.Buffer) confPath = viper.ConfigFileUsed() + buf = new(bytes.Buffer) + err error ) - if confPath == "" { home, err := homedir.Dir() if err != nil { @@ -240,11 +326,10 @@ func promptConfigureSetup() error { "path", confPath, ) } else { - cli.Log.Debugw("decoding config", "path", confPath) - if _, err := toml.DecodeFile(confPath, &profiles); err != nil { - return errors.Wrap(err, "unable to decode profiles from config") + profiles, err = cli.LoadProfiles() + if err != nil { + return err } - cli.Log.Debugw("profiles loaded from config, updating", "profiles", profiles) } profiles[cli.Profile] = newCreds @@ -253,7 +338,7 @@ func promptConfigureSetup() error { return err } - err := ioutil.WriteFile(confPath, buf.Bytes(), 0600) + err = ioutil.WriteFile(confPath, buf.Bytes(), 0600) if err != nil { return err } @@ -274,3 +359,30 @@ func loadKeysFromJsonFile(file string) (*apiKeyDetails, error) { err = json.Unmarshal(jsonData, &auth) return &auth, err } + +func buildProfilesTableContent(current string, profiles Profiles) [][]string { + out := [][]string{} + for profile, creds := range profiles { + out = append(out, []string{ + profile, + creds.Account, + creds.ApiKey, + formatSecret(4, creds.ApiSecret), + }) + } + + // order by severity + sort.Slice(out, func(i, j int) bool { + return out[i][0] < out[j][0] + }) + + for i := range out { + if out[i][0] == current { + out[i][0] = fmt.Sprintf("> %s", out[i][0]) + } else { + out[i][0] = fmt.Sprintf(" %s", out[i][0]) + } + } + + return out +} diff --git a/cli/cmd/event.go b/cli/cmd/event.go index 751357061..3d1da2537 100644 --- a/cli/cmd/event.go +++ b/cli/cmd/event.go @@ -150,7 +150,8 @@ For example, to list all events from the last day with severity medium and above // eventShowCmd represents the show sub-command inside the event command eventShowCmd = &cobra.Command{ Use: "show ", - Short: "Show details about a specific event", + Short: "show details about a specific event", + Long: "Show details about a specific event.", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { cli.Log.Infow("requesting event details", "event_id", args[0]) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index e9000dd45..1e5647a65 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -39,8 +39,7 @@ var ( Use: "lacework", Short: "A tool to manage the Lacework cloud security platform.", SilenceErrors: true, - Long: ` -The Lacework Command Line Interface is a tool that helps you manage the + Long: `The Lacework Command Line Interface is a tool that helps you manage the Lacework cloud security platform. Use it to manage compliance reports, external integrations, vulnerability scans, and other operations. @@ -54,6 +53,10 @@ This will prompt you for your Lacework account and a set of API access keys.`, case "help [command]", "configure", "version", "generate-pkg-manifest": return nil default: + // @afiune no need to create a client for any configure command + if cmd.HasParent() && cmd.Parent().Use == "configure" { + return nil + } return cli.NewClient() } }, @@ -162,7 +165,9 @@ func initConfig() { cli.Log.Debugw("configuration file not found") } else { // the config file was found but another error was produced - exitwith(errors.Wrap(err, "Error: unable to read in config")) + errcheckWARN(rootCmd.Help()) + cli.OutputHuman("\n") + exitwith(errors.Wrap(err, "unable to read in config file ~/.lacework.toml")) } } else { cli.Log.Debugw("using configuration file", diff --git a/integration/configure_list_test.go b/integration/configure_list_test.go new file mode 100644 index 000000000..d65c4d126 --- /dev/null +++ b/integration/configure_list_test.go @@ -0,0 +1,121 @@ +// +// Author:: Salim Afiune Maya () +// Copyright:: Copyright 2020, Lacework Inc. +// License:: Apache License, Version 2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfigureListCommandWithConfig(t *testing.T) { + out, err, exitcode := LaceworkCLIWithDummyConfig("configure", "list") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") + + expectedFields := []string{ + // headers + "PROFILE", + "ACCOUNT", + "API KEY", + "API SECRET", + + // column 1 + "> default", + "dummy", + "DUMMY_1234567890abcdefg", + "*************cret", + + // column 2 + "dev", + "dev.example", + "DEVDEV_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AAABBBCCC000", + "*****************************1111", + + // column 3 + "integration", + "integration", + "INTEGRATION_3DF1234AABBCCDD5678XXYYZZ1234ABC8BEC6500DC70", + "*****************************4abc", + + // column 3 + "test", + "test.account", + "INTTEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AAABBBCCC00", + "*****************************0000", + } + t.Run("verify table fields", func(t *testing.T) { + for _, field := range expectedFields { + assert.Contains(t, out.String(), field, + "STDOUT something intside the table is missing, please check") + } + }) +} + +func TestConfigureListCommandWithoutConfig(t *testing.T) { + out, err, exitcode := LaceworkCLI("configure", "list") + assert.Contains(t, err.String(), "ERROR unable to load profiles. No configuration file found.", + "STDERR message changed, please check") + assert.Empty(t, + out.String(), + "STDOUT should be empty") + assert.Equal(t, 1, exitcode, + "EXITCODE is not the expected one") +} + +func TestConfigureListCommandWithConfigAndProfile(t *testing.T) { + out, err, exitcode := LaceworkCLIWithDummyConfig("configure", "list") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") + + t.Run("verify selected profile: > default", func(t *testing.T) { + assert.Contains(t, out.String(), "> default", + "STDOUT something intside the table is missing, please check") + }) + + out, err, exitcode = LaceworkCLIWithDummyConfig("configure", "list", "-p", "integration") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") + + t.Run("verify selected profile: > integration", func(t *testing.T) { + assert.Contains(t, out.String(), "> integration", + "STDOUT something intside the table is missing, please check") + }) + + out, err, exitcode = LaceworkCLIWithDummyConfig("configure", "list", "--profile", "dev") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") + + t.Run("verify selected profile: > dev", func(t *testing.T) { + assert.Contains(t, out.String(), "> dev", + "STDOUT something intside the table is missing, please check") + }) +} diff --git a/integration/configure_show_test.go b/integration/configure_show_test.go new file mode 100644 index 000000000..ec185b93f --- /dev/null +++ b/integration/configure_show_test.go @@ -0,0 +1,108 @@ +// +// Author:: Salim Afiune Maya () +// Copyright:: Copyright 2020, Lacework Inc. +// License:: Apache License, Version 2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfigureShowCommandWrongKey(t *testing.T) { + out, err, exitcode := LaceworkCLIWithDummyConfig("configure", "show", "foo") + assert.Empty(t, + out.String(), + "STDOUT should be empty") + assert.Contains(t, err.String(), "unknown configuration key.", + "STDERR is not correct, please update") + assert.Contains(t, err.String(), "(available: profile, account, api_secret, api_key)", + "STDERR is not correct, please update") + assert.Equal(t, 1, exitcode, + "EXITCODE is not the expected one") +} + +func TestConfigureShowCommandWithConfig(t *testing.T) { + out, err, exitcode := LaceworkCLIWithDummyConfig("configure", "show", "profile") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") + assert.Contains(t, out.String(), "default", + "STDOUT wrong computed profile") +} + +func TestConfigureShowCommandWithoutConfig(t *testing.T) { + out, err, exitcode := LaceworkCLI("configure", "show", "account") + assert.Empty(t, + err.String(), + "STDOUT should be empty") + assert.Empty(t, + out.String(), + "STDOUT should be empty") + assert.Equal(t, 1, exitcode, + "EXITCODE is not the expected one") +} + +func TestConfigureShowCommandWithConfigAndProfile(t *testing.T) { + t.Run("dev.account", func(t *testing.T) { + out, err, exitcode := LaceworkCLIWithDummyConfig("configure", "show", "account", "-p", "dev") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") + assert.Equal(t, "dev.example\n", out.String(), + "STDOUT does not match with the correct value") + }) + + t.Run("integration.api_secret", func(t *testing.T) { + out, err, exitcode := LaceworkCLIWithDummyConfig("configure", "show", "api_secret", "-p", "integration") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") + assert.Equal(t, "_1234abdc00ff11vv22zz33xyz1234abc\n", out.String(), + "STDOUT does not match with the correct value") + }) + + t.Run("test.api_key", func(t *testing.T) { + out, err, exitcode := LaceworkCLIWithDummyConfig("configure", "show", "api_key", "-p", "test") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") + assert.Equal(t, "INTTEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AAABBBCCC00\n", out.String(), + "STDOUT does not match with the correct value") + }) + + t.Run("foo.unknown", func(t *testing.T) { + out, err, exitcode := LaceworkCLIWithDummyConfig("configure", "show", "account", "-p", "foo") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Equal(t, 1, exitcode, + "EXITCODE is not the expected one") + assert.Empty(t, + out.String(), + "STDERR should be empty") + }) +} diff --git a/integration/configure_unix_test.go b/integration/configure_unix_test.go index 9c326d233..19619a619 100644 --- a/integration/configure_unix_test.go +++ b/integration/configure_unix_test.go @@ -270,7 +270,20 @@ func TestConfigureCommandWithJSONFileFlagError(t *testing.T) { assert.Contains(t, err.String(), "ERROR unable to load keys from the provided json file: open foo: no such file or directory", - "STDERR should be empty") + "STDERR error message changed, please check") assert.Equal(t, 1, exitcode, "EXITCODE is not the expected one") } + +func TestConfigureListHelp(t *testing.T) { + out, err, exitcode := LaceworkCLI("configure", "list", "--help") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Contains(t, + out.String(), + "$ export LW_PROFILE=\"my-profile\"", + "STDOUT the environment variable in the help message is not correct") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") +} diff --git a/integration/configure_windows_test.go b/integration/configure_windows_test.go index 12c5e662c..607668e9b 100644 --- a/integration/configure_windows_test.go +++ b/integration/configure_windows_test.go @@ -37,3 +37,16 @@ func TestConfigureCommandWithJSONFileFlagError(t *testing.T) { assert.Equal(t, 1, exitcode, "EXITCODE is not the expected one") } + +func TestConfigureListHelp(t *testing.T) { + out, err, exitcode := LaceworkCLI("configure", "list", "--help") + assert.Empty(t, + err.String(), + "STDERR should be empty") + assert.Contains(t, + out.String(), + `C:\> $env:LW_PROFILE = 'my-profile'`, + "STDOUT the environment variable in the help message is not correct") + assert.Equal(t, 0, exitcode, + "EXITCODE is not the expected one") +} diff --git a/integration/framework_test.go b/integration/framework_test.go index 312d90ee4..44122ebc0 100644 --- a/integration/framework_test.go +++ b/integration/framework_test.go @@ -172,6 +172,21 @@ func createDummyTOMLConfig() string { account = 'dummy' api_key = 'DUMMY_1234567890abcdefg' api_secret = '_superdummysecret' + +[test] +account = 'test.account' +api_key = 'INTTEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AAABBBCCC00' +api_secret = '_00000000000000000000000000000000' + +[integration] +account = 'integration' +api_key = 'INTEGRATION_3DF1234AABBCCDD5678XXYYZZ1234ABC8BEC6500DC70' +api_secret = '_1234abdc00ff11vv22zz33xyz1234abc' + +[dev] +account = 'dev.example' +api_key = 'DEVDEV_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AAABBBCCC000' +api_secret = '_11111111111111111111111111111111' `) err = ioutil.WriteFile(configFile, c, 0644) if err != nil { diff --git a/integration/help_test.go b/integration/help_test.go index e10d3cabb..ff9311202 100644 --- a/integration/help_test.go +++ b/integration/help_test.go @@ -40,8 +40,7 @@ func TestHelpCommand(t *testing.T) { func TestHelpCommandForConfigureCommand(t *testing.T) { out, err, exitcode := LaceworkCLI("help", "configure") assert.Equal(t, - ` -Configure settings that the Lacework CLI uses to interact with the Lacework + `Configure settings that the Lacework CLI uses to interact with the Lacework platform. These include your Lacework account, API access key and secret. To create a set of API keys, log in to your Lacework account via WebUI and @@ -49,19 +48,24 @@ navigate to Settings > API Keys and click + Create New. Enter a name for the key and an optional description, then click Save. To get the secret key, download the generated API key file. -Use the argument --json_file to preload the downloaded API key file. +Use the flag --json_file to preload the downloaded API key file. -If this command is run with no arguments, the Lacework CLI will store all +If this command is run with no flags, the Lacework CLI will store all settings under the default profile. The information in the default profile is used any time you run a Lacework CLI command that doesn't explicitly specify a profile to use. -You can configure multiple profiles by using the --profile argument. If a -config file does not exist (the default location is ~/.lacework.toml), the -Lacework CLI will create it for you. +You can configure multiple profiles by using the --profile flag. If a +config file does not exist (the default location is ~/.lacework.toml), +the Lacework CLI will create it for you. Usage: lacework configure [flags] + lacework configure [command] + +Available Commands: + list list all configured profiles at ~/.lacework.toml + show show current configuration data Flags: -h, --help help for configure @@ -76,6 +80,8 @@ Global Flags: --nocolor turn off colors --noninteractive turn off interactive mode (disable spinners, prompts, etc.) -p, --profile string switch between profiles configured at ~/.lacework.toml + +Use "lacework configure [command] --help" for more information about a command. `, out.String(), "the configure help message changed, please update") @@ -120,8 +126,7 @@ func TestCommandDoesNotExist(t *testing.T) { func TestNoCommandProvided(t *testing.T) { out, err, exitcode := LaceworkCLI() assert.Equal(t, - ` -The Lacework Command Line Interface is a tool that helps you manage the + `The Lacework Command Line Interface is a tool that helps you manage the Lacework cloud security platform. Use it to manage compliance reports, external integrations, vulnerability scans, and other operations.