diff --git a/internal/api/account.go b/internal/api/account.go index e169a9d..3a2d278 100644 --- a/internal/api/account.go +++ b/internal/api/account.go @@ -86,6 +86,7 @@ func (a accountAPI) Create(ctx context.Context, i AccountCreateInput) (*Account, Account Account } `graphql:"addAccount(input: $input)"` } + ctx = maskSecrets(ctx, i.SecurityContext) input := map[string]any{"input": i} if err := a.c.Mutate(ctx, &mutation, input); err != nil { return nil, err @@ -101,6 +102,7 @@ func (a accountAPI) Update(ctx context.Context, i AccountUpdateInput) (*Account, Account Account } `graphql:"updateAccount(input: $input)"` } + ctx = maskSecrets(ctx, i.SecurityContext) input := map[string]any{"input": i} if err := a.c.Mutate(ctx, &mutation, input); err != nil { return nil, err diff --git a/internal/api/account_discovery.go b/internal/api/account_discovery.go index 0be3dcc..116e9e2 100644 --- a/internal/api/account_discovery.go +++ b/internal/api/account_discovery.go @@ -150,6 +150,7 @@ func (a accountDiscoveryAPI) UpsertAzure(ctx context.Context, input AccountDisco AccountDiscovery AccountDiscovery } `graphql:"upsertAzureAccountDiscovery(input: $input)"` } + ctx = maskSecrets(ctx, input.ClientSecret) variables := map[string]any{"input": input} if err := a.c.Mutate(ctx, &mutation, variables); err != nil { return nil, err @@ -164,6 +165,7 @@ func (a accountDiscoveryAPI) UpsertGCP(ctx context.Context, input AccountDiscove AccountDiscovery AccountDiscovery } `graphql:"upsertGCPAccountDiscovery(input: $input)"` } + ctx = maskSecrets(ctx, input.CredentialJSON) variables := map[string]any{"input": input} if err := a.c.Mutate(ctx, &mutation, variables); err != nil { return nil, err diff --git a/internal/api/api.go b/internal/api/api.go index 384cc92..233f121 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -3,10 +3,6 @@ // package api provides access to the GraphQL API. package api -import ( - "context" -) - // API provides access to the GraphQL API. type API struct { Account accountAPI @@ -33,8 +29,8 @@ type API struct { } // New creates an API wrapper. -func New(ctx context.Context, config ClientConfig) *API { - c := newClient(ctx, config) +func New(config ClientConfig) *API { + c := newClient(config) return &API{ Account: accountAPI{c}, AccountDiscovery: accountDiscoveryAPI{c}, diff --git a/internal/api/binding.go b/internal/api/binding.go index ff45e35..54adf1d 100644 --- a/internal/api/binding.go +++ b/internal/api/binding.go @@ -69,6 +69,13 @@ type BindingExecutionConfig struct { Variables *string `graphql:"variables" json:"variables"` } +func (c BindingExecutionConfig) securityContext() *string { + if c.SecurityContext == nil { + return nil + } + return &c.SecurityContext.Default +} + // BindingExecutionConfigDryRun holds the dry run configuration for a binding execution config. type BindingExecutionConfigDryRun struct { Default bool `graphql:"default" json:"default"` @@ -158,6 +165,7 @@ func (a bindingAPI) Create(ctx context.Context, i BindingCreateInput) (*Binding, Binding Binding } `graphql:"addBinding(input: $input)"` } + ctx = maskSecrets(ctx, i.ExecutionConfig.securityContext()) input := map[string]any{"input": i} if err := a.c.Mutate(ctx, &mutation, input); err != nil { return nil, err @@ -173,6 +181,7 @@ func (a bindingAPI) Update(ctx context.Context, i BindingUpdateInput) (*Binding, Binding Binding } `graphql:"updateBinding(input: $input)"` } + ctx = maskSecrets(ctx, i.ExecutionConfig.securityContext()) input := map[string]any{"input": i} if err := a.c.Mutate(ctx, &mutation, input); err != nil { return nil, err diff --git a/internal/api/client.go b/internal/api/client.go index 5479be9..7c6ac8e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -49,8 +49,29 @@ func (c *client) Mutate(ctx context.Context, m any, variables map[string]any) er return nil } +// maskSecrets returns a context whose logs show the given secrets only as a +// mask. Request bodies carry them JSON-encoded, so that form is masked too. +func maskSecrets(ctx context.Context, secrets ...*string) context.Context { + var masked []string + for _, s := range secrets { + if s == nil || *s == "" { + continue + } + masked = append(masked, *s) + if encoded, err := json.Marshal(*s); err == nil { + if e := string(encoded[1 : len(encoded)-1]); e != *s { + masked = append(masked, e) + } + } + } + if len(masked) == 0 { + return ctx + } + return tflog.MaskLogStrings(ctx, masked...) +} + // newClient returns a configured graphql Client. -func newClient(ctx context.Context, config ClientConfig) *client { +func newClient(config ClientConfig) *client { tfLog := hclog.LevelFromString(os.Getenv("TF_LOG")) logBody := tfLog == hclog.Debug || tfLog == hclog.Trace @@ -60,7 +81,6 @@ func newClient(ctx context.Context, config ClientConfig) *client { APIKey: config.APIKey, Version: config.Version, Base: &logTransport{ - Ctx: ctx, Base: http.DefaultTransport, LogBody: logBody, }, @@ -89,9 +109,9 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.Base.RoundTrip(req) } -// logTransport is an http.Transport that logs requests/responses. +// logTransport is an http.Transport that logs requests/responses through the +// request's context, so the logs apply whatever masking the caller set up. type logTransport struct { - Ctx context.Context Base http.RoundTripper LogBody bool } @@ -109,7 +129,7 @@ func (t *logTransport) RoundTrip(req *http.Request) (*http.Response, error) { } tflog.Debug( - t.Ctx, + req.Context(), "Performing GraphQL request", map[string]any{ "req_method": req.Method, @@ -129,7 +149,7 @@ func (t *logTransport) RoundTrip(req *http.Request) (*http.Response, error) { } tflog.Debug( - t.Ctx, + req.Context(), "Got GraphQL response", map[string]any{ "req_method": req.Method, diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 708885a..12dab9b 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -3,11 +3,15 @@ package api import ( + "bytes" + "context" + "fmt" "io" "net/http" "net/http/httptest" "testing" + "github.com/hashicorp/terraform-plugin-log/tflogtest" "github.com/stretchr/testify/assert" ) @@ -95,6 +99,39 @@ func TestErrorTransport_400WithoutGraphQLErrors(t *testing.T) { assert.Equal(t, "not json", string(body)) } +func TestMutationMasksSecretsInDebugLogs(t *testing.T) { + t.Setenv("TF_LOG", "DEBUG") + apiKey := "plain-secret-api-key" + // JSON-encoding escapes this one, so the request body carries a form that + // differs from the value itself. + appKey := "app\"secret\nkey" + + var reqBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + reqBody = string(body) + _, _ = w.Write([]byte(`{"data":{"updateDatadogIntegration":{"datadogIntegration":{"configured":true,"statusInfo":{"details":[]}},"problems":[]}}}`)) + })) + defer server.Close() + + var output bytes.Buffer + ctx := tflogtest.RootLogger(context.Background(), &output) + api := New(ClientConfig{Endpoint: server.URL, APIKey: "token"}) + _, err := api.DatadogIntegration.Update(ctx, DatadogIntegrationInput{APIKey: &apiKey, AppKey: &appKey}) + assert.NoError(t, err) + assert.Contains(t, reqBody, apiKey, "the request itself carries the secret") + + entries, err := tflogtest.MultilineJSONDecode(&output) + assert.NoError(t, err) + assert.Len(t, entries, 2) + for _, entry := range entries { + logged := fmt.Sprint(entry["req_body"]) + assert.Contains(t, logged, `"apiKey":"***"`) + assert.Contains(t, logged, `"appKey":"***"`) + assert.NotContains(t, logged, "secret") + } +} + func TestErrorTransport_NonBadRequest(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/api/configuration_profile.go b/internal/api/configuration_profile.go index 3edfe89..5fc14fb 100644 --- a/internal/api/configuration_profile.go +++ b/internal/api/configuration_profile.go @@ -361,6 +361,7 @@ func (a configurationProfileAPI) UpsertJira(ctx context.Context, input JiraConfi Configuration ConfigurationProfile } `graphql:"addJiraProfile(input: $input)"` } + ctx = maskSecrets(ctx, &input.APIKey) variables := map[string]any{ "input": jiraConfigurationInput{ JiraConfiguration: input, @@ -437,6 +438,11 @@ func (a configurationProfileAPI) UpsertSlack(ctx context.Context, config SlackCo Configuration ConfigurationProfile } `graphql:"addSlackProfile(input: $input)"` } + secrets := []*string{config.Token} + for _, w := range config.Webhooks { + secrets = append(secrets, &w.URL) + } + ctx = maskSecrets(ctx, secrets...) variables := map[string]any{ "input": slackConfigurationInput{ SlackConfiguration: config, @@ -463,6 +469,7 @@ func (a configurationProfileAPI) UpsertServiceNow(ctx context.Context, config Se Configuration ConfigurationProfile } `graphql:"addServiceNowProfile(input: $input)"` } + ctx = maskSecrets(ctx, &config.Password) variables := map[string]any{ "input": serviceNowConfigurationInput{ ServiceNowConfiguration: config, @@ -489,6 +496,7 @@ func (a configurationProfileAPI) UpsertSymphony(ctx context.Context, config Symp Configuration ConfigurationProfile } `graphql:"addSymphonyProfile(input: $input)"` } + ctx = maskSecrets(ctx, &config.PrivateKey) variables := map[string]any{ "input": symphonyConfigurationInput{ SymphonyConfiguration: config, @@ -515,6 +523,9 @@ func (a configurationProfileAPI) UpsertEmail(ctx context.Context, input EmailCon Configuration ConfigurationProfile } `graphql:"addEmailProfile(input: $input)"` } + if input.SMTP != nil { + ctx = maskSecrets(ctx, input.SMTP.Password) + } variables := map[string]any{ "input": emailConfigurationInput{ EmailConfiguration: input, diff --git a/internal/api/datadog_integration.go b/internal/api/datadog_integration.go index 6ef60fe..3997eaa 100644 --- a/internal/api/datadog_integration.go +++ b/internal/api/datadog_integration.go @@ -58,6 +58,7 @@ func (a datadogIntegrationAPI) Update(ctx context.Context, input DatadogIntegrat Problems []problem } `graphql:"updateDatadogIntegration(input: $input)"` } + ctx = maskSecrets(ctx, input.APIKey, input.AppKey) if err := a.c.Mutate(ctx, &mutation, map[string]any{"input": input}); err != nil { return nil, err } diff --git a/internal/api/repository.go b/internal/api/repository.go index c42b850..1ca95eb 100644 --- a/internal/api/repository.go +++ b/internal/api/repository.go @@ -71,6 +71,19 @@ func (i *RepositoryConfigAuthInput) GetGraphQLType() string { return "RepositoryConfigAuthInput" } +func (i *RepositoryConfigAuthInput) secrets() []*string { + if i == nil { + return nil + } + var secrets []*string + for _, key := range []string{"authToken", "sshPrivateKey", "sshPassphrase"} { + if v, ok := i.m[key].(*string); ok { + secrets = append(secrets, v) + } + } + return secrets +} + type RepositoryCreateInput struct { URL string `json:"url"` Name string `json:"name"` @@ -169,6 +182,7 @@ func (a repositoryAPI) Create(ctx context.Context, i RepositoryCreateInput) (*Re Problems []problem } `graphql:"addRepositoryConfig(input: $input)"` } + ctx = maskSecrets(ctx, i.Auth.secrets()...) if err := a.c.Mutate(ctx, &m, map[string]any{"input": i}); err != nil { return nil, err } @@ -185,6 +199,7 @@ func (a repositoryAPI) Update(ctx context.Context, i RepositoryUpdateInput) (*Re Problems []problem } `graphql:"updateRepositoryConfig(input: $input)"` } + ctx = maskSecrets(ctx, i.Auth.secrets()...) if err := a.c.Mutate(ctx, &m, map[string]any{"input": i}); err != nil { return nil, err } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4dcfe56..45e0d09 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -114,7 +114,6 @@ func (p *stackletProvider) Configure(ctx context.Context, req provider.Configure // Make provider data accessible to the Configure method of resources and data sources providerData := providerdata.New( - ctx, api.ClientConfig{ Endpoint: creds.Endpoint, APIKey: creds.APIKey, diff --git a/internal/providerdata/providerdata.go b/internal/providerdata/providerdata.go index 9968f11..96bcd36 100644 --- a/internal/providerdata/providerdata.go +++ b/internal/providerdata/providerdata.go @@ -3,7 +3,6 @@ package providerdata import ( - "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -18,9 +17,9 @@ type providerData struct { } // New returns configured provider data. -func New(ctx context.Context, config api.ClientConfig) *providerData { +func New(config api.ClientConfig) *providerData { return &providerData{ - API: api.New(ctx, config), + API: api.New(config), } }