diff --git a/.github/docs/contribution-guide/cmd.go b/.github/docs/contribution-guide/cmd.go index d9184fb00..1cb1109b9 100644 --- a/.github/docs/contribution-guide/cmd.go +++ b/.github/docs/contribution-guide/cmd.go @@ -22,8 +22,9 @@ import ( // Define consts for command flags const ( - someArg = "MY_ARG" - someFlag = "my-flag" + someArg = "MY_ARG" + someFlag = "my-flag" + secretFlag = "secret" ) // Struct to model user input (arguments and/or flags) @@ -31,6 +32,7 @@ type inputModel struct { *globalflags.GlobalFlagModel MyArg string MyFlag *string + Secret *string } // "bar" command constructor @@ -85,8 +87,10 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Configure command flags (type, default value, and description) -func configureFlags(cmd *cobra.Command) { +func configureFlags(cmd *cobra.Command, params *types.CmdParams) { cmd.Flags().StringP(someFlag, "shorthand", "defaultValue", "My flag description") + secret := flags.SecretFlag(secretFlag, params) + cmd.Flags().Var(secret, secretFlag, secret.Usage()) } // Parse user input (arguments and/or flags) @@ -102,6 +106,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu GlobalFlagModel: globalFlags, MyArg: myArg, MyFlag: flags.FlagToStringPointer(p, cmd, someFlag), + Secret: flags.SecretFlagToStringPointer(p, cmd, secretFlag), } // Write the input model to the debug logs diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 47d79abda..0c1363917 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -76,6 +76,13 @@ For prints that are specific to a certain log level, you can use the methods def For command outputs that should always be displayed, no matter the defined verbosity, you should use the `print` methods `Outputf` and `Outputln`. These should only be used for the actual output of the commands, which can usually be described by "I ran the command to see _this_". +#### Handling secrets + +If your command needs secrets as input, please make sure to use `flags.SecretFlag()` and `flags.SecretFlagToStringPointer()`. +These functions implement reading from stdin or a file. + +They also support reading the secret value as a command line argument (deprecated, marked for removal in Oct 2026). + ### Onboarding a new STACKIT service If you want to add a command that uses a STACKIT service `foo` that was not yet used by the CLI, you will first need to implement a few extra steps to configure the new service: diff --git a/docs/stackit_beta_alb_observability-credentials_add.md b/docs/stackit_beta_alb_observability-credentials_add.md index 9e4e544cc..4c8ca39a5 100644 --- a/docs/stackit_beta_alb_observability-credentials_add.md +++ b/docs/stackit_beta_alb_observability-credentials_add.md @@ -14,7 +14,7 @@ stackit beta alb observability-credentials add [flags] ``` Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag - $ stackit beta alb observability-credentials add --username xxx --password @./password.txt --display-name yyy + $ stackit beta alb observability-credentials add --username xxx --password @./password.txt --displayname yyy ``` ### Options @@ -22,7 +22,7 @@ stackit beta alb observability-credentials add [flags] ``` -d, --displayname string Displayname for the credentials -h, --help Help for "stackit beta alb observability-credentials add" - --password string Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt). + --password string password. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty. -u, --username string Username for the credentials ``` diff --git a/internal/cmd/beta/alb/observability-credentials/add/add.go b/internal/cmd/beta/alb/observability-credentials/add/add.go index 69d21973b..c306cd04a 100644 --- a/internal/cmd/beta/alb/observability-credentials/add/add.go +++ b/internal/cmd/beta/alb/observability-credentials/add/add.go @@ -39,7 +39,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Example: examples.Build( examples.NewExample( `Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag`, - "$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --display-name yyy"), + "$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --displayname yyy"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -71,14 +71,16 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return outputResult(params.Printer, model.OutputFormat, resp) }, } - configureFlags(cmd) + configureFlags(cmd, params) return cmd } -func configureFlags(cmd *cobra.Command) { +func configureFlags(cmd *cobra.Command, params *types.CmdParams) { cmd.Flags().StringP(usernameFlag, "u", "", "Username for the credentials") cmd.Flags().StringP(displaynameFlag, "d", "", "Displayname for the credentials") - cmd.Flags().Var(flags.ReadFromFileFlag(), passwordFlag, `Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).`) + + password := flags.SecretFlag(passwordFlag, params) + cmd.Flags().Var(password, passwordFlag, password.Usage()) cobra.CheckErr(flags.MarkFlagsRequired(cmd, usernameFlag, displaynameFlag)) } @@ -90,7 +92,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, GlobalFlagModel: globalFlags, Username: flags.FlagToStringPointer(p, cmd, usernameFlag), Displayname: flags.FlagToStringPointer(p, cmd, displaynameFlag), - Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + Password: flags.SecretFlagToStringPointer(p, cmd, passwordFlag), } p.DebugInputModel(model) diff --git a/internal/pkg/flags/secret.go b/internal/pkg/flags/secret.go new file mode 100644 index 000000000..9214869b6 --- /dev/null +++ b/internal/pkg/flags/secret.go @@ -0,0 +1,78 @@ +package flags + +import ( + "fmt" + "io/fs" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type secretFlag struct { + printer *print.Printer + fs fs.FS + value string + name string +} + +func SecretFlag(name string, params *types.CmdParams) *secretFlag { + f := &secretFlag{ + printer: params.Printer, + fs: params.Fs, + name: name, + } + return f +} + +var _ pflag.Value = &secretFlag{} + +func (f *secretFlag) String() string { + return f.value +} + +func (f *secretFlag) Set(value string) error { + if strings.HasPrefix(value, "@") { + path := strings.Trim(value[1:], `"'`) + bytes, err := fs.ReadFile(f.fs, path) + if err != nil { + return fmt.Errorf("reading secret %s: %w", f.name, err) + } + f.value = string(bytes) + return nil + } + f.printer.Warn("Passing a secret value on the command line is insecure and deprecated. This usage will stop working October 2026.\n") + f.value = value + return nil +} + +func (f *secretFlag) Type() string { + return "string" +} + +func (f *secretFlag) Usage() string { + return fmt.Sprintf("%s. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty.", f.name) +} + +func SecretFlagToStringPointer(p *print.Printer, cmd *cobra.Command, flag string) *string { + value, err := cmd.Flags().GetString(flag) + if err != nil { + p.Debug(print.ErrorLevel, "convert secret flag to string pointer: %v", err) + return nil + } + if value == "" { + input, err := p.PromptForPassword(fmt.Sprintf("enter %s: ", flag)) + if err != nil { + p.Debug(print.ErrorLevel, "convert secret flag %q to string pointer: %v", flag, err) + return nil + } + return &input + } + if cmd.Flag(flag).Changed { + return &value + } + return nil +} diff --git a/internal/pkg/flags/secret_test.go b/internal/pkg/flags/secret_test.go new file mode 100644 index 000000000..e6af88576 --- /dev/null +++ b/internal/pkg/flags/secret_test.go @@ -0,0 +1,124 @@ +package flags + +import ( + "io" + "testing" + "testing/fstest" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testFile struct { + path, content string +} + +func TestSecretFlag(t *testing.T) { + t.Parallel() + tests := []struct { + name string + value string + want *string + file *testFile + stdin string + wantErr bool + wantStdErr string + }{ + { + name: "no value: prompts", + value: "", + want: utils.Ptr("from stdin"), + stdin: "from stdin", + }, + { + name: "a value: prints deprecation", + value: "a value", + want: utils.Ptr("a value"), + wantStdErr: "Warning: Passing a secret value on the command line is insecure and deprecated. This usage will stop working October 2026.\n", + }, + { + name: "from an existing file", + value: "@some-file.txt", + want: utils.Ptr("from file"), + file: &testFile{ + path: "some-file.txt", + content: "from file", + }, + }, + { + name: "from a non-existing file", + value: "@some-file-with-typo.txt", + wantErr: true, + file: &testFile{ + path: "some-file.txt", + content: "from file", + }, + }, + { + name: "from an existing double-quoted file", + value: `@"some-file.txt"`, + want: utils.Ptr("from file"), + file: &testFile{ + path: "some-file.txt", + content: "from file", + }, + }, + { + name: "from an existing single-quoted file", + value: "@'some-file.txt'", + want: utils.Ptr("from file"), + file: &testFile{ + path: "some-file.txt", + content: "from file", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + params := testparams.NewTestParams() + if tt.file != nil { + params.Fs = fstest.MapFS{ + tt.file.path: &fstest.MapFile{ + Data: []byte(tt.file.content), + }, + } + } + flag := SecretFlag("test", params.CmdParams) + cmd := cobra.Command{} + cmd.Flags().Var(flag, "test", flag.Usage()) + if tt.stdin != "" { + params.In.WriteString(tt.stdin) + params.In.WriteString("\n") + } + + if tt.value != "" { // emulate pflag only calling set when flag is specified on the command line + err := cmd.Flags().Set("test", tt.value) + if err != nil && !tt.wantErr { + t.Fatalf("unexpected error: %v", err) + } + if err == nil && tt.wantErr { + t.Fatalf("expected error, got none") + } + } + + got := SecretFlagToStringPointer(params.Printer, &cmd, "test") + + if got != tt.want && *got != *tt.want { + t.Fatalf("unexpected value: got %q, want %q", *got, *tt.want) + } + if tt.wantStdErr != "" { + message, err := params.Err.ReadString('\n') + if err != nil && err != io.EOF { + t.Fatalf("reading stderr: %v", err) + } + if message != tt.wantStdErr { + t.Fatalf("unexpected stderr: got %q, want %q", message, tt.wantStdErr) + } + } + }) + } +}