diff --git a/pkg/secrethub/client.go b/pkg/secrethub/client.go index 882969e9..674accb9 100644 --- a/pkg/secrethub/client.go +++ b/pkg/secrethub/client.go @@ -4,6 +4,7 @@ package secrethub import ( "os" + "regexp" "runtime" "strings" @@ -52,6 +53,13 @@ type ClientInterface interface { var ( errClient = errio.Namespace("client") + + whitelistAppInfoName = regexp.MustCompile("^[a-zA-Z0-9_-]{2,50}$") +) + +// Errors +var ( + ErrInvalidAppInfoName = errClient.Code("invalid_app_info_name").Error("name must be 2-50 characters long, only alphanumeric, underscore (_), and dash (-)") ) // Client is a client for the SecretHub HTTP API. @@ -72,7 +80,7 @@ type Client struct { // These are cached repoIndexKeys map[api.RepoPath]*crypto.SymmetricKey - appInfo *AppInfo + appInfo []*AppInfo ConfigDir *configdir.Dir } @@ -83,7 +91,7 @@ type AppInfo struct { Version string } -func (i AppInfo) userAgentSuffix() string { +func (i AppInfo) userAgentComponent() string { res := i.Name if i.Version != "" { res += "/" + strings.TrimPrefix(i.Version, "v") @@ -91,6 +99,15 @@ func (i AppInfo) userAgentSuffix() string { return res } +// ValidateName returns an error if the provided app name is not set or doesn't match alphanumeric, underscore (_), and dash (-) characters, or length of 2-50 characters. +func (i AppInfo) ValidateName() error { + if i.Name == "" || !whitelistAppInfoName.MatchString(i.Name) { + return ErrInvalidAppInfoName + } + + return nil +} + // NewClient creates a new SecretHub client. Provided options are applied to the client. // // If no WithCredentials() option is provided, the client tries to find a key credential at the following locations (in order): @@ -102,6 +119,7 @@ func NewClient(with ...ClientOption) (*Client, error) { client := &Client{ httpClient: http.NewClient(), repoIndexKeys: make(map[api.RepoPath]*crypto.SymmetricKey), + appInfo: []*AppInfo{}, } err := client.with(with...) if err != nil { @@ -139,6 +157,19 @@ func NewClient(with ...ClientOption) (*Client, error) { } } + appName := os.Getenv("SECRETHUB_APP_INFO_NAME") + if appName != "" { + appVersion := os.Getenv("SECRETHUB_APP_INFO_VERSION") + topLevelAppInfo := &AppInfo{ + Name: appName, + Version: appVersion, + } + // Ignore app info from environment variable if name is invalid + if err = topLevelAppInfo.ValidateName(); err == nil { + client.appInfo = append(client.appInfo, topLevelAppInfo) + } + } + userAgent := client.userAgent() client.httpClient.Options(http.WithUserAgent(userAgent)) @@ -235,8 +266,8 @@ func (c *Client) DefaultCredential() credentials.Reader { func (c *Client) userAgent() string { userAgent := userAgentPrefix - if c.appInfo != nil { - userAgent += " " + c.appInfo.userAgentSuffix() + for _, info := range c.appInfo { + userAgent += " " + info.userAgentComponent() } osName, err := operatingsystem.GetOperatingSystem() if err != nil { diff --git a/pkg/secrethub/client_options.go b/pkg/secrethub/client_options.go index 591930e7..7ad43796 100644 --- a/pkg/secrethub/client_options.go +++ b/pkg/secrethub/client_options.go @@ -1,7 +1,6 @@ package secrethub import ( - "errors" "net/http" "net/url" "time" @@ -51,10 +50,10 @@ func WithTransport(transport http.RoundTripper) ClientOption { // WithAppInfo sets the AppInfo to be used for identifying the application that is using the SecretHub Client. func WithAppInfo(appInfo *AppInfo) ClientOption { return func(c *Client) error { - if appInfo.Name == "" { - return errors.New("name must be set for AppInfo") + if err := appInfo.ValidateName(); err != nil { + return err } - c.appInfo = appInfo + c.appInfo = append(c.appInfo, appInfo) return nil } } diff --git a/pkg/secrethub/client_test.go b/pkg/secrethub/client_test.go new file mode 100644 index 00000000..3b924356 --- /dev/null +++ b/pkg/secrethub/client_test.go @@ -0,0 +1,80 @@ +package secrethub + +import ( + "os" + "regexp" + "testing" + + "github.com/secrethub/secrethub-go/internals/assert" +) + +func TestClient_userAgent(t *testing.T) { + cases := map[string]struct { + appInfo []*AppInfo + envAppName string + envAppVersion string + expected string + err error + }{ + "default": {}, + "multiple app info layers": { + appInfo: []*AppInfo{ + {Name: "secrethub-xgo", Version: "0.1.0"}, + {Name: "secrethub-java", Version: "0.2.0"}, + }, + expected: "secrethub-xgo/0.1.0 secrethub-java/0.2.0", + }, + "no version number": { + appInfo: []*AppInfo{ + {Name: "terraform-provider-secrethub"}, + }, + expected: "terraform-provider-secrethub", + }, + "top level app info from environment": { + appInfo: []*AppInfo{ + {Name: "secrethub-cli", Version: "0.37.0"}, + }, + envAppName: "secrethub-circleci-orb", + envAppVersion: "1.0.0", + expected: "secrethub-cli/0.37.0 secrethub-circleci-orb/1.0.0", + }, + "invalid app name": { + appInfo: []*AppInfo{ + {Name: "illegal-name*%!@", Version: "0.1.0"}, + }, + err: ErrInvalidAppInfoName, + }, + "ignore faulty environment variable": { + appInfo: []*AppInfo{ + {Name: "secrethub-cli", Version: "0.37.0"}, + }, + envAppName: "illegal-name*%!@", + expected: "secrethub-cli/0.37.0", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + os.Setenv("SECRETHUB_APP_INFO_NAME", tc.envAppName) + os.Setenv("SECRETHUB_APP_INFO_VERSION", tc.envAppVersion) + + var opts []ClientOption + for _, info := range tc.appInfo { + opts = append(opts, WithAppInfo(info)) + } + client, err := NewClient(opts...) + assert.Equal(t, err, tc.err) + if err != nil { + return + } + + userAgent := client.userAgent() + pattern := tc.expected + " \\(.*\\)" + matched, err := regexp.MatchString(pattern, userAgent) + assert.OK(t, err) + if !matched { + t.Errorf("user agent '%s' doesn't match pattern '%s'", userAgent, pattern) + } + }) + } +}