From 75db98fbb4161cd72caf0708c7338b9447699246 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:27:10 +0100 Subject: [PATCH 1/3] fix(config): mask secret key input during exo config add [sc-170579] --- .github/workflows/golangci-lint.yml | 1 - CHANGELOG.md | 2 ++ cmd/config/config_add.go | 12 ++++++++++-- go.mod | 2 ++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 77e662073..b605b6034 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,4 +15,3 @@ jobs: version: latest args: --timeout 4m only-new-issues: true - diff --git a/CHANGELOG.md b/CHANGELOG.md index c344ee990..32e33bbf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ ### Bug fixes +- fix(config): secret key no longer shown in plain text during `exo config add` #810 + - fix(nlb): API error swallowed on load-balancer update (e.g. duplicate name conflict reported as "operation is nil") #806 - fix(config): panic when used without a default account set #798 diff --git a/cmd/config/config_add.go b/cmd/config/config_add.go index 5105712fc..f58a3c586 100644 --- a/cmd/config/config_add.go +++ b/cmd/config/config_add.go @@ -11,6 +11,7 @@ import ( "github.com/manifoldco/promptui" "github.com/spf13/cobra" + "golang.org/x/term" exocmd "github.com/exoscale/cli/cmd" "github.com/exoscale/cli/pkg/account" @@ -181,16 +182,23 @@ func promptAccountInformation() (*account.Account, error) { account.Key = apiKey // Prompt for Secret Key with validation - secretKey, err := readInputWithContext(ctx, reader, "Secret Key") + fd := int(os.Stdin.Fd()) + fmt.Printf("[+] Secret Key: ") //nolint:errcheck + secretKeyBytes, err := term.ReadPassword(fd) + fmt.Println() //nolint:errcheck if err != nil { return nil, err } + secretKey := strings.TrimSpace(string(secretKeyBytes)) for secretKey == "" { fmt.Println("Secret Key cannot be empty") - secretKey, err = readInputWithContext(ctx, reader, "Secret Key") + fmt.Printf("[+] Secret Key: ") //nolint:errcheck + secretKeyBytes, err = term.ReadPassword(fd) + fmt.Println() //nolint:errcheck if err != nil { return nil, err } + secretKey = strings.TrimSpace(string(secretKeyBytes)) } account.Secret = secretKey diff --git a/go.mod b/go.mod index bdd13ccca..e76cbad14 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/exoscale/cli go 1.25.8 +toolchain go1.25.8 + require ( github.com/aws/aws-sdk-go-v2 v1.2.0 github.com/aws/aws-sdk-go-v2/config v1.1.1 From 692cbe3c7e301f2eb30f77b2315fa066b0f1da5e Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:00:54 +0100 Subject: [PATCH 2/3] chore: remove redundant toolchain directive from go.mod --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index e76cbad14..bdd13ccca 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/exoscale/cli go 1.25.8 -toolchain go1.25.8 - require ( github.com/aws/aws-sdk-go-v2 v1.2.0 github.com/aws/aws-sdk-go-v2/config v1.1.1 From 504be1f39f2d6116eed01e85613b3e0453735afa Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:40:17 +0100 Subject: [PATCH 3/3] fix(config): handle SIGINT gracefully during secret key prompt --- cmd/config/config_add.go | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/cmd/config/config_add.go b/cmd/config/config_add.go index f58a3c586..5bf6ee310 100644 --- a/cmd/config/config_add.go +++ b/cmd/config/config_add.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "os/signal" "strings" "github.com/manifoldco/promptui" @@ -129,6 +130,36 @@ func addConfigAccount(firstRun bool) error { return saveConfig(filePath, &config) } +// readPasswordInterruptible reads a password from the terminal (no echo) while +// catching SIGINT (Ctrl+C). term.ReadPassword enables ISIG on the fd, which +// would otherwise deliver SIGINT directly to the process and kill it before any +// cancellation message can be printed. By intercepting the signal we can exit +// gracefully with the expected "Error: Operation Cancelled" output. +func readPasswordInterruptible() ([]byte, error) { + fd := int(os.Stdin.Fd()) + + sigCh := make(chan os.Signal, 1) + doneCh := make(chan struct{}) + signal.Notify(sigCh, os.Interrupt) + + go func() { + select { + case _, ok := <-sigCh: + if ok { + fmt.Println() + fmt.Fprintln(os.Stderr, "Error: Operation Cancelled") + os.Exit(exocmd.ExitCodeInterrupt) + } + case <-doneCh: + } + }() + + b, err := term.ReadPassword(fd) + signal.Stop(sigCh) + close(doneCh) + return b, err +} + // readInputWithContext reads a line from stdin with context cancellation support. // Returns io.EOF if Ctrl+C or Ctrl+D is pressed, allowing graceful cancellation. // Silent exit behavior matches promptui.Select's interrupt handling. @@ -182,9 +213,8 @@ func promptAccountInformation() (*account.Account, error) { account.Key = apiKey // Prompt for Secret Key with validation - fd := int(os.Stdin.Fd()) fmt.Printf("[+] Secret Key: ") //nolint:errcheck - secretKeyBytes, err := term.ReadPassword(fd) + secretKeyBytes, err := readPasswordInterruptible() fmt.Println() //nolint:errcheck if err != nil { return nil, err @@ -193,7 +223,7 @@ func promptAccountInformation() (*account.Account, error) { for secretKey == "" { fmt.Println("Secret Key cannot be empty") fmt.Printf("[+] Secret Key: ") //nolint:errcheck - secretKeyBytes, err = term.ReadPassword(fd) + secretKeyBytes, err = readPasswordInterruptible() fmt.Println() //nolint:errcheck if err != nil { return nil, err