diff --git a/go.mod b/go.mod index 1756d233..1a334d45 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect @@ -59,6 +60,7 @@ require ( github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect @@ -68,6 +70,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-github/v30 v30.1.0 // indirect @@ -109,6 +112,7 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect diff --git a/go.sum b/go.sum index ee9b9711..3e9a9aae 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= @@ -60,6 +62,8 @@ github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -113,6 +117,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -299,6 +305,8 @@ github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBe github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= diff --git a/settings/keychain.go b/settings/keychain.go new file mode 100644 index 00000000..fb0cc139 --- /dev/null +++ b/settings/keychain.go @@ -0,0 +1,17 @@ +package settings + +import "github.com/zalando/go-keyring" + +const KeychainService = "com.circleci.cli" + +func GetTokenFromKeychain(host string) (string, error) { + return keyring.Get(KeychainService, host) +} + +func SetTokenInKeychain(host, token string) error { + return keyring.Set(KeychainService, host, token) +} + +func DeleteTokenFromKeychain(host string) error { + return keyring.Delete(KeychainService, host) +} diff --git a/settings/settings.go b/settings/settings.go index d1224265..f968fa8a 100644 --- a/settings/settings.go +++ b/settings/settings.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/zalando/go-keyring" "gopkg.in/yaml.v3" "github.com/spf13/afero" @@ -166,6 +167,25 @@ func (cfg *Config) LoadFromDisk() error { return nil } + if cfg.Host != "" { + keychainToken, keychainErr := GetTokenFromKeychain(cfg.Host) + if keychainErr == nil && keychainToken != "" { + cfg.Token = keychainToken + } else if errors.Is(keychainErr, keyring.ErrNotFound) && cfg.Token != "" { + // Migrate: YAML has token but keychain doesn't — move it silently + if setErr := SetTokenInKeychain(cfg.Host, cfg.Token); setErr == nil { + savedToken := cfg.Token + cfg.Token = "" + enc, merr := yaml.Marshal(cfg) + cfg.Token = savedToken + if merr == nil { + _ = os.WriteFile(cfg.FileUsed, enc, 0600) + } + } + } + // If keychain is unavailable (other error), YAML token is used as-is (silent fallback) + } + cfg.Stdout = os.Stdout cfg.Stderr = os.Stderr @@ -174,13 +194,21 @@ func (cfg *Config) LoadFromDisk() error { // WriteToDisk will write the runtime config instance to disk by serializing the YAML func (cfg *Config) WriteToDisk() error { + tokenForFile := cfg.Token + if cfg.Host != "" && cfg.Token != "" { + if err := SetTokenInKeychain(cfg.Host, cfg.Token); err == nil { + tokenForFile = "" // stored in keychain; don't write plaintext + } + } + + savedToken := cfg.Token + cfg.Token = tokenForFile enc, err := yaml.Marshal(&cfg) + cfg.Token = savedToken if err != nil { return err } - - err = os.WriteFile(cfg.FileUsed, enc, 0600) - return err + return os.WriteFile(cfg.FileUsed, enc, 0600) } // LoadFromEnv will read from environment variables of the given prefix for host, endpoint, and token specifically. diff --git a/settings/settings_test.go b/settings/settings_test.go index b98f3806..44a49d53 100644 --- a/settings/settings_test.go +++ b/settings/settings_test.go @@ -7,10 +7,15 @@ import ( "strings" "testing" + "github.com/zalando/go-keyring" "github.com/CircleCI-Public/circleci-cli/settings" "gotest.tools/v3/assert" ) +func init() { + keyring.MockInit() +} + func TestWithHTTPClient(t *testing.T) { table := []struct { label string