diff --git a/internal/cli/auth/login.go b/internal/cli/auth/login.go index 8692e7f0..0c060c2b 100644 --- a/internal/cli/auth/login.go +++ b/internal/cli/auth/login.go @@ -58,7 +58,7 @@ func LoginCmd(h *internal.Helper) *cobra.Command { if err != nil { return err } - opts.client.SetDebug(debug) + util.ConfigureRestyDebug(opts.client, debug) return nil }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cli/auth/logout.go b/internal/cli/auth/logout.go index 19719fa5..04a0904e 100644 --- a/internal/cli/auth/logout.go +++ b/internal/cli/auth/logout.go @@ -21,6 +21,7 @@ import ( "github.com/tidbcloud/tidbcloud-cli/internal/config" "github.com/tidbcloud/tidbcloud-cli/internal/config/store" "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/util" ver "github.com/tidbcloud/tidbcloud-cli/internal/version" "github.com/fatih/color" @@ -50,7 +51,7 @@ func LogoutCmd(h *internal.Helper) *cobra.Command { if err != nil { return err } - opts.client.SetDebug(debug) + util.ConfigureRestyDebug(opts.client, debug) return nil }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cli/auth/whoami.go b/internal/cli/auth/whoami.go index bdea4cf8..bdf23bf5 100644 --- a/internal/cli/auth/whoami.go +++ b/internal/cli/auth/whoami.go @@ -26,6 +26,7 @@ import ( "github.com/tidbcloud/tidbcloud-cli/internal/config/store" "github.com/tidbcloud/tidbcloud-cli/internal/flag" "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/internal/util" ver "github.com/tidbcloud/tidbcloud-cli/internal/version" "github.com/fatih/color" @@ -56,7 +57,7 @@ func WhoamiCmd(h *internal.Helper) *cobra.Command { if err != nil { return err } - opts.client.SetDebug(debug) + util.ConfigureRestyDebug(opts.client, debug) return nil }, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cli/config/describe.go b/internal/cli/config/describe.go index aa9edb1c..a6a7361b 100644 --- a/internal/cli/config/describe.go +++ b/internal/cli/config/describe.go @@ -21,6 +21,7 @@ import ( "github.com/tidbcloud/tidbcloud-cli/internal" "github.com/tidbcloud/tidbcloud-cli/internal/config" "github.com/tidbcloud/tidbcloud-cli/internal/output" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" "github.com/juju/errors" "github.com/spf13/cobra" @@ -42,7 +43,7 @@ func DescribeCmd(h *internal.Helper) *cobra.Command { return err } - value := viper.Get(name) + value := redact.MaskAny(viper.Get(name)) err = output.PrintJson(h.IOStreams.Out, value) return errors.Trace(err) diff --git a/internal/cli/config/describe_test.go b/internal/cli/config/describe_test.go index ff574dc6..4e74af92 100644 --- a/internal/cli/config/describe_test.go +++ b/internal/cli/config/describe_test.go @@ -54,6 +54,7 @@ func (suite *DescribeConfigSuite) SetupTest() { viper.Set("test.public-key", publicKey) viper.Set("test.private-key", privateKey) + viper.Set("test.access-token", "raw-access-token") viper.Set("current-profile", profile) err := viper.WriteConfig() if err != nil { @@ -81,12 +82,12 @@ func (suite *DescribeConfigSuite) TestDescribeConfigArgs() { { name: "describe config", args: []string{"test"}, - stdoutString: "{\n \"private-key\": \"SDWIOUEOSDSDC\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", + stdoutString: "{\n \"access-token\": \"******\",\n \"private-key\": \"******\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", }, { name: "describe config case-insensitive", args: []string{"teSt"}, - stdoutString: "{\n \"private-key\": \"SDWIOUEOSDSDC\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", + stdoutString: "{\n \"access-token\": \"******\",\n \"private-key\": \"******\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", }, { name: "describe config with no args", @@ -126,6 +127,7 @@ func (suite *DescribeConfigSuite) TestDescribeConfigWithSpecialCharacters() { viper.Set("~`!@#$%^&*()_+-={}[]\\|;:,<>/?.public-key", publicKey) viper.Set("~`!@#$%^&*()_+-={}[]\\|;:,<>/?.private-key", privateKey) + viper.Set("~`!@#$%^&*()_+-={}[]\\|;:,<>/?.access-token", "raw-access-token") viper.Set("current-profile", newProfile) err := viper.WriteConfig() @@ -143,7 +145,7 @@ func (suite *DescribeConfigSuite) TestDescribeConfigWithSpecialCharacters() { { name: "describe active profile", args: []string{"~`!@#$%^&*()_+-={}[]\\|;:,<>/?"}, - stdoutString: "{\n \"private-key\": \"SDWIOUEOSDSDC\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", + stdoutString: "{\n \"access-token\": \"******\",\n \"private-key\": \"******\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", }, } diff --git a/internal/cli/config/set.go b/internal/cli/config/set.go index d1290ad7..3e7b00e2 100644 --- a/internal/cli/config/set.go +++ b/internal/cli/config/set.go @@ -21,6 +21,7 @@ import ( "github.com/tidbcloud/tidbcloud-cli/internal" "github.com/tidbcloud/tidbcloud-cli/internal/config" "github.com/tidbcloud/tidbcloud-cli/internal/prop" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" "github.com/fatih/color" "github.com/juju/errors" @@ -61,7 +62,7 @@ If not, the config in the active profile will be set`, prop.ProfileProperties()) } } viper.Set(fmt.Sprintf("%s.%s", curP, propertyName), value) - res = fmt.Sprintf("Set profile `%s` property `%s` to value `%s` successfully", curP, propertyName, value) + res = fmt.Sprintf("Set profile `%s` property `%s` to value `%s` successfully", curP, propertyName, redact.MaskValue(propertyName, value)) } else { return fmt.Errorf("unrecognized property `%s`, use `config set --help` to find available properties", propertyName) } diff --git a/internal/cli/config/set_test.go b/internal/cli/config/set_test.go index 8e284e95..e1473cca 100644 --- a/internal/cli/config/set_test.go +++ b/internal/cli/config/set_test.go @@ -86,7 +86,7 @@ func (suite *SetConfigSuite) TestSetConfigArgs() { { name: "set config", args: []string{"private-key", newPrivateKey}, - stdoutString: "Set profile `test` property `private-key` to value `TYTYTYYTYT` successfully\n", + stdoutString: "Set profile `test` property `private-key` to value `******` successfully\n", }, { name: "set config with no args", @@ -158,7 +158,7 @@ func (suite *SetConfigSuite) TestSetConfigWhenNoActiveProfile() { { name: "set config", args: []string{"private-key", "value"}, - stdoutString: "Set profile `default` property `private-key` to value `value` successfully\n", + stdoutString: "Set profile `default` property `private-key` to value `******` successfully\n", }, } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 9ca7a49c..dd327847 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -88,28 +88,28 @@ func (suite *RootCmdSuite) TestFlagProfile() { { name: "test without flag profile", args: []string{"config", "set", "private-key", privateKey3}, - stdoutString: "Set profile `test` property `private-key` to value `324OPIFO2423423DFO` successfully\n", + stdoutString: "Set profile `test` property `private-key` to value `******` successfully\n", propertyKey: "test.private-key", propertyValue: "324OPIFO2423423DFO", }, { name: "test flag --profile", args: []string{"config", "set", "private-key", privateKey1, "--profile", "test1"}, - stdoutString: "Set profile `test1` property `private-key` to value `SAJKGDUYAKGD` successfully\n", + stdoutString: "Set profile `test1` property `private-key` to value `******` successfully\n", propertyKey: "test1.private-key", propertyValue: "SAJKGDUYAKGD", }, { name: "test flag -P", args: []string{"config", "set", "private-key", privateKey2, "-P", "test1"}, - stdoutString: "Set profile `test1` property `private-key` to value `{OPIFOPIDFO` successfully\n", + stdoutString: "Set profile `test1` property `private-key` to value `******` successfully\n", propertyKey: "test1.private-key", propertyValue: "{OPIFOPIDFO", }, { name: "test flag -P case-insensitive", args: []string{"config", "set", "private-key", "SADASDIDFO", "-P", "tESt1"}, - stdoutString: "Set profile `test1` property `private-key` to value `SADASDIDFO` successfully\n", + stdoutString: "Set profile `test1` property `private-key` to value `******` successfully\n", propertyKey: "test1.private-key", propertyValue: "SADASDIDFO", }, diff --git a/internal/security/debug_dump_test.go b/internal/security/debug_dump_test.go new file mode 100644 index 00000000..2e26e217 --- /dev/null +++ b/internal/security/debug_dump_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package security + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestRawHTTPDumpUsageIsCentralized(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate test file") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "../..")) + allowed := filepath.Join(repoRoot, "pkg/tidbcloud/redact/redact.go") + needles := []string{"httputil." + "DumpRequestOut", "httputil." + "DumpResponse"} + + for _, dir := range []string{"internal", "pkg/tidbcloud"} { + root := filepath.Join(repoRoot, dir) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + for _, needle := range needles { + if strings.Contains(string(content), needle) && path != allowed { + t.Fatalf("raw HTTP dump %q must go through redaction helper, found in %s", needle, path) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/service/aws/s3/uploader.go b/internal/service/aws/s3/uploader.go index c1fcfaee..aaf296de 100644 --- a/internal/service/aws/s3/uploader.go +++ b/internal/service/aws/s3/uploader.go @@ -20,11 +20,9 @@ import ( "fmt" "io" "math" - "os" "sort" "sync" - "github.com/tidbcloud/tidbcloud-cli/internal/config" "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" "github.com/tidbcloud/tidbcloud-cli/internal/util" "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/imp" @@ -163,8 +161,6 @@ type UploaderImpl struct { // cloud.TiDBCloudClient. func NewUploader(client cloud.TiDBCloudClient) Uploader { httpClient := resty.New() - debug := os.Getenv(config.DebugEnv) != "" - httpClient.SetDebug(debug) u := &UploaderImpl{ PartSize: DefaultUploadPartSize, Concurrency: DefaultUploadConcurrency, diff --git a/internal/service/aws/s3/uploader_debug_test.go b/internal/service/aws/s3/uploader_debug_test.go new file mode 100644 index 00000000..0ab20c23 --- /dev/null +++ b/internal/service/aws/s3/uploader_debug_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s3 + +import ( + "testing" + + "github.com/tidbcloud/tidbcloud-cli/internal/config" +) + +func TestNewUploaderDoesNotEnableRawRestyDebugForPresignedURLs(t *testing.T) { + t.Setenv(config.DebugEnv, "1") + + uploader, ok := NewUploader(nil).(*UploaderImpl) + if !ok { + t.Fatalf("unexpected uploader type %T", uploader) + } + if uploader.httpClient.Debug { + t.Fatal("raw Resty debug must stay disabled for pre-signed upload URLs") + } +} diff --git a/internal/service/cloud/api_client.go b/internal/service/cloud/api_client.go index 760aba20..b5899f12 100644 --- a/internal/service/cloud/api_client.go +++ b/internal/service/cloud/api_client.go @@ -20,12 +20,12 @@ import ( "fmt" "io" "net/http" - "net/http/httputil" "os" "github.com/tidbcloud/tidbcloud-cli/internal/config" "github.com/tidbcloud/tidbcloud-cli/internal/prop" "github.com/tidbcloud/tidbcloud-cli/internal/version" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/iam" "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/auditlog" "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/br" @@ -854,7 +854,7 @@ func (dt *DebugTransport) RoundTrip(r *http.Request) (*http.Response, error) { debug := os.Getenv(config.DebugEnv) == "true" || os.Getenv(config.DebugEnv) == "1" if debug { - dump, err := httputil.DumpRequestOut(r, true) + dump, err := redact.DumpRequestOut(r, true) if err != nil { return nil, err } @@ -867,7 +867,7 @@ func (dt *DebugTransport) RoundTrip(r *http.Request) (*http.Response, error) { } if debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/internal/service/cloud/api_client_test.go b/internal/service/cloud/api_client_test.go new file mode 100644 index 00000000..69526a3b --- /dev/null +++ b/internal/service/cloud/api_client_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloud + +import ( + "bytes" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/tidbcloud/tidbcloud-cli/internal/config" +) + +func TestDebugTransportRedactsRequestAndResponse(t *testing.T) { + t.Setenv(config.DebugEnv, "1") + + var stdout bytes.Buffer + restore := captureStdout(t, &stdout) + + transport := NewTransportWithBearToken(NewDebugTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + if !strings.Contains(string(body), "raw-secret") { + t.Fatalf("inner transport did not receive original body: %s", body) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Set-Cookie": []string{"session=raw-cookie"}, + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"access_token":"raw-access","name":"visible"}`)), + Request: req, + }, nil + })), "raw-bearer") + + req, err := http.NewRequest(http.MethodPost, "https://example.com/import?X-Amz-Signature=raw-signature&safe=visible", strings.NewReader(`{"secret":"raw-secret","name":"visible"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(respBody), "raw-access") { + t.Fatalf("response body was not restored after debug dump: %s", respBody) + } + + restore() + got := stdout.String() + for _, secret := range []string{"raw-bearer", "raw-signature", "raw-secret", "raw-cookie", "raw-access"} { + if strings.Contains(got, secret) { + t.Fatalf("debug output leaked %q: %s", secret, got) + } + } + if !strings.Contains(got, "safe=visible") || !strings.Contains(got, `"name":"visible"`) || !strings.Contains(got, "******") { + t.Fatalf("debug output lost expected context or masks: %s", got) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func captureStdout(t *testing.T, dst *bytes.Buffer) func() { + t.Helper() + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(dst, reader) + close(done) + }() + return func() { + _ = writer.Close() + <-done + os.Stdout = original + _ = reader.Close() + } +} diff --git a/internal/util/download.go b/internal/util/download.go index acb579e6..0e8a676a 100644 --- a/internal/util/download.go +++ b/internal/util/download.go @@ -24,15 +24,14 @@ import ( "runtime" "strings" "unicode" - - "github.com/go-resty/resty/v2" ) // GetResponse returns the response of a given AWS per-signed URL func GetResponse(url string, debug bool) (*http.Response, error) { - httpClient := resty.New() - httpClient.SetDebug(debug) - resp, err := httpClient.GetClient().Get(url) // nolint:gosec + // Do not enable raw HTTP debug for pre-signed URLs. Their query string can + // contain credentials such as signatures and security tokens. + _ = debug + resp, err := http.Get(url) // nolint:gosec if err != nil { return nil, err } diff --git a/internal/util/resty_debug.go b/internal/util/resty_debug.go new file mode 100644 index 00000000..a6f467dc --- /dev/null +++ b/internal/util/resty_debug.go @@ -0,0 +1,41 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "github.com/go-resty/resty/v2" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" +) + +// ConfigureRestyDebug enables Resty's debug mode with redaction callbacks. +// Resty does not expose a callback for the request URI in its debug log, so do +// not use this helper for pre-signed URL transfers. +func ConfigureRestyDebug(client *resty.Client, debug bool) { + client.SetDebug(debug) + if !debug { + return + } + + client.OnRequestLog(func(log *resty.RequestLog) error { + log.Header = redact.RedactHeader(log.Header) + log.Body = redact.RedactBodyString(log.Body) + return nil + }) + client.OnResponseLog(func(log *resty.ResponseLog) error { + log.Header = redact.RedactHeader(log.Header) + log.Body = redact.RedactBodyString(log.Body) + return nil + }) +} diff --git a/internal/util/resty_debug_test.go b/internal/util/resty_debug_test.go new file mode 100644 index 00000000..ecbfa5a7 --- /dev/null +++ b/internal/util/resty_debug_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-resty/resty/v2" +) + +func TestConfigureRestyDebugRedactsLogs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Set-Cookie", "session=raw-cookie") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"raw-access-token","name":"visible"}`) + })) + defer server.Close() + + logger := &bufferLogger{} + client := resty.New() + client.SetLogger(logger) + ConfigureRestyDebug(client, true) + + resp, err := client.R(). + SetHeader("Authorization", "Bearer raw-bearer"). + SetHeader("Content-Type", "application/json"). + SetBody(map[string]string{ + "client_secret": "raw-client-secret", + "name": "visible", + }). + Post(server.URL) + if err != nil { + t.Fatal(err) + } + if !resp.IsSuccess() { + t.Fatalf("unexpected status: %s", resp.Status()) + } + + got := logger.String() + for _, secret := range []string{"raw-bearer", "raw-client-secret", "raw-cookie", "raw-access-token"} { + if strings.Contains(got, secret) { + t.Fatalf("debug log leaked %q: %s", secret, got) + } + } + if !strings.Contains(got, "visible") || !strings.Contains(got, "******") { + t.Fatalf("debug log did not keep expected context and masks: %s", got) + } +} + +type bufferLogger struct { + bytes.Buffer +} + +func (l *bufferLogger) Errorf(format string, args ...interface{}) { + fmt.Fprintf(&l.Buffer, format, args...) +} + +func (l *bufferLogger) Warnf(format string, args ...interface{}) { + fmt.Fprintf(&l.Buffer, format, args...) +} + +func (l *bufferLogger) Debugf(format string, args ...interface{}) { + fmt.Fprintf(&l.Buffer, format, args...) +} diff --git a/pkg/tidbcloud/redact/redact.go b/pkg/tidbcloud/redact/redact.go new file mode 100644 index 00000000..8c96ee3d --- /dev/null +++ b/pkg/tidbcloud/redact/redact.go @@ -0,0 +1,338 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package redact + +import ( + "bytes" + "encoding/json" + "fmt" + "mime" + "net/http" + "net/http/httputil" + "net/url" + "reflect" + "regexp" + "strings" + "unicode" +) + +const Mask = "******" + +var ( + sensitiveKeys = map[string]struct{}{ + "authorization": {}, + "proxyauthorization": {}, + "cookie": {}, + "setcookie": {}, + + "accesskey": {}, + "accesskeyid": {}, + "accesskeysecret": {}, + "accesstoken": {}, + "awsaccesskeyid": {}, + "clientsecret": {}, + "credential": {}, + "devicecode": {}, + "googleaccessid": {}, + "oauthclientsecret": {}, + "password": {}, + "privatekey": {}, + "refreshtoken": {}, + "sastoken": {}, + "secret": {}, + "secretaccesskey": {}, + "securitytoken": {}, + "serviceaccountkey": {}, + "sig": {}, + "signature": {}, + "token": {}, + + "xamzcredential": {}, + "xamzsecuritytoken": {}, + "xamzsignature": {}, + "xgoogcredential": {}, + "xgoogsecuritytoken": {}, + "xgoogsignature": {}, + "xosssecuritytoken": {}, + } + + assignmentPattern = regexp.MustCompile(`(?i)(access[-_]?token|refresh[-_]?token|client[-_]?secret|oauth[-_]?client[-_]?secret|private[-_]?key|secret[-_]?access[-_]?key|access[-_]?key[-_]?secret|service[-_]?account[-_]?key|sas[-_]?token|password|token|secret)\s*([:=])\s*("[^"]*"|'[^']*'|[^\s,&}]+)`) + bearerPattern = regexp.MustCompile(`(?i)(Bearer\s+)[A-Za-z0-9._~+/=-]+`) +) + +// IsSensitiveKey reports whether a header, query parameter, or body field name +// commonly carries credentials or tokens. +func IsSensitiveKey(key string) bool { + _, ok := sensitiveKeys[normalizeKey(key)] + return ok +} + +// MaskValue returns Mask for sensitive key names and the original value otherwise. +func MaskValue(key, value string) string { + if IsSensitiveKey(key) { + return Mask + } + return value +} + +// MaskAny returns a redacted deep copy of maps and slices. Scalar values are +// masked only when their parent key is sensitive. +func MaskAny(value interface{}) interface{} { + return maskAny(reflect.ValueOf(value), "") +} + +// RedactHeader returns a redacted copy of h. +func RedactHeader(h http.Header) http.Header { + if h == nil { + return nil + } + redacted := h.Clone() + for key := range redacted { + if IsSensitiveKey(key) { + redacted[key] = []string{Mask} + } + } + return redacted +} + +// RedactURL redacts credential-bearing query parameters in a URL or request URI. +func RedactURL(raw string) string { + if raw == "" { + return raw + } + u, err := url.Parse(raw) + if err != nil { + return raw + } + q := u.Query() + changed := false + for key, values := range q { + if IsSensitiveKey(key) { + q[key] = maskedValues(values) + changed = true + } + } + if !changed { + return raw + } + u.RawQuery = q.Encode() + return u.String() +} + +// RedactBodyString redacts sensitive fields in JSON or form-like bodies. +func RedactBodyString(body string) string { + if strings.TrimSpace(body) == "" { + return body + } + + var v interface{} + decoder := json.NewDecoder(strings.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&v); err == nil { + redacted, err := json.Marshal(MaskAny(v)) + if err == nil { + return string(redacted) + } + } + + if values, err := url.ParseQuery(body); err == nil && len(values) > 0 { + changed := false + for key, value := range values { + if IsSensitiveKey(key) { + values[key] = maskedValues(value) + changed = true + } + } + if changed { + return values.Encode() + } + } + + return RedactText(body) +} + +// RedactBody redacts body bytes. JSON content is detected even if contentType is +// missing because HTTP dumps do not always retain enough context. +func RedactBody(body []byte, contentType string) []byte { + if len(bytes.TrimSpace(body)) == 0 { + return body + } + if isJSONContent(contentType) || json.Valid(body) || isFormContent(contentType) { + return []byte(RedactBodyString(string(body))) + } + return []byte(RedactText(string(body))) +} + +// RedactText applies conservative fallback redaction for non-JSON text. +func RedactText(text string) string { + text = bearerPattern.ReplaceAllString(text, "${1}"+Mask) + return assignmentPattern.ReplaceAllString(text, "$1$2"+Mask) +} + +// DumpRequestOut is httputil.DumpRequestOut with sensitive headers, query +// parameters, and body fields masked before returning bytes to callers. +func DumpRequestOut(req *http.Request, body bool) ([]byte, error) { + dump, err := httputil.DumpRequestOut(req, body) + if err != nil { + return nil, err + } + return RedactHTTPDump(dump), nil +} + +// DumpResponse is httputil.DumpResponse with sensitive headers and body fields +// masked before returning bytes to callers. +func DumpResponse(resp *http.Response, body bool) ([]byte, error) { + dump, err := httputil.DumpResponse(resp, body) + if err != nil { + return nil, err + } + return RedactHTTPDump(dump), nil +} + +// RedactHTTPDump redacts a raw HTTP request or response dump. +func RedactHTTPDump(dump []byte) []byte { + head, body, sep := splitHTTPDump(dump) + redactedHead := redactHTTPHead(string(head)) + if sep == "" { + return []byte(redactedHead) + } + return []byte(redactedHead + sep + string(RedactBody(body, headerContentType(redactedHead)))) +} + +func maskAny(value reflect.Value, parentKey string) interface{} { + if !value.IsValid() { + return nil + } + if parentKey != "" && IsSensitiveKey(parentKey) { + return Mask + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return nil + } + value = value.Elem() + } + + switch value.Kind() { + case reflect.Map: + out := make(map[string]interface{}, value.Len()) + for _, key := range value.MapKeys() { + keyString := fmt.Sprint(key.Interface()) + out[keyString] = maskAny(value.MapIndex(key), keyString) + } + return out + case reflect.Slice, reflect.Array: + out := make([]interface{}, value.Len()) + for i := 0; i < value.Len(); i++ { + out[i] = maskAny(value.Index(i), parentKey) + } + return out + default: + return value.Interface() + } +} + +func normalizeKey(key string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return unicode.ToLower(r) + } + return -1 + }, key) +} + +func maskedValues(values []string) []string { + if len(values) == 0 { + return []string{Mask} + } + out := make([]string, len(values)) + for i := range out { + out[i] = Mask + } + return out +} + +func isJSONContent(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = contentType + } + mediaType = strings.ToLower(mediaType) + return mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") +} + +func isFormContent(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = contentType + } + return strings.EqualFold(mediaType, "application/x-www-form-urlencoded") +} + +func splitHTTPDump(dump []byte) ([]byte, []byte, string) { + if idx := bytes.Index(dump, []byte("\r\n\r\n")); idx >= 0 { + return dump[:idx], dump[idx+4:], "\r\n\r\n" + } + if idx := bytes.Index(dump, []byte("\n\n")); idx >= 0 { + return dump[:idx], dump[idx+2:], "\n\n" + } + return dump, nil, "" +} + +func redactHTTPHead(head string) string { + lines := strings.Split(head, "\n") + for i, line := range lines { + line = strings.TrimSuffix(line, "\r") + if i == 0 { + lines[i] = redactStartLine(line) + continue + } + idx := strings.Index(line, ":") + if idx < 0 { + lines[i] = line + continue + } + name := line[:idx] + value := strings.TrimSpace(line[idx+1:]) + if IsSensitiveKey(name) { + lines[i] = name + ": " + Mask + continue + } + lines[i] = name + ": " + RedactURL(value) + } + return strings.Join(lines, "\n") +} + +func redactStartLine(line string) string { + parts := strings.Split(line, " ") + if len(parts) == 3 && strings.HasPrefix(parts[2], "HTTP/") { + parts[1] = RedactURL(parts[1]) + return strings.Join(parts, " ") + } + return RedactURL(line) +} + +func headerContentType(head string) string { + for _, line := range strings.Split(head, "\n") { + idx := strings.Index(line, ":") + if idx < 0 { + continue + } + if strings.EqualFold(strings.TrimSpace(line[:idx]), "Content-Type") { + return strings.TrimSpace(line[idx+1:]) + } + } + return "" +} diff --git a/pkg/tidbcloud/redact/redact_test.go b/pkg/tidbcloud/redact/redact_test.go new file mode 100644 index 00000000..1d273908 --- /dev/null +++ b/pkg/tidbcloud/redact/redact_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package redact + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestDumpRequestOutRedactsSensitiveData(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://example.com/upload?X-Amz-Signature=raw-signature&token=raw-token&safe=visible", strings.NewReader(`{"accessKey":{"id":"raw-access-key-id","secret":"raw-secret"},"nested":{"serviceAccountKey":"raw-gcs-key"},"name":"visible"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer raw-bearer") + req.Header.Set("Cookie", "session=raw-cookie") + req.Header.Set("Content-Type", "application/json") + + dump, err := DumpRequestOut(req, true) + if err != nil { + t.Fatal(err) + } + got := string(dump) + + assertNotContains(t, got, "raw-signature", "raw-token", "raw-access-key-id", "raw-secret", "raw-gcs-key", "raw-bearer", "raw-cookie") + assertContains(t, got, "safe=visible", `"name":"visible"`, Mask) +} + +func TestDumpResponseRedactsSensitiveData(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Set-Cookie": []string{"session=raw-cookie"}, + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"visible-request-id"}, + "Authorization": []string{"Bearer raw-bearer"}, + }, + Body: io.NopCloser(strings.NewReader(`{"access_token":"raw-access-token","display":"visible"}`)), + } + + dump, err := DumpResponse(resp, true) + if err != nil { + t.Fatal(err) + } + got := string(dump) + + assertNotContains(t, got, "raw-cookie", "raw-bearer", "raw-access-token") + assertContains(t, got, "visible-request-id", `"display":"visible"`, Mask) +} + +func TestMaskAnyPreservesNonSensitiveValues(t *testing.T) { + input := map[string]interface{}{ + "public-key": "public", + "private-key": "private", + "oauth-client-secret": "client-secret", + "nested": map[string]string{ + "sasToken": "sas-token", + "region": "us-west-2", + }, + } + + got := MaskAny(input).(map[string]interface{}) + nested := got["nested"].(map[string]interface{}) + + if got["public-key"] != "public" { + t.Fatalf("public key should not be masked: %#v", got["public-key"]) + } + if got["private-key"] != Mask || got["oauth-client-secret"] != Mask || nested["sasToken"] != Mask { + t.Fatalf("sensitive fields were not masked: %#v", got) + } + if nested["region"] != "us-west-2" { + t.Fatalf("non-sensitive nested field changed: %#v", nested["region"]) + } +} + +func TestRedactURLMasksAzureSASSignature(t *testing.T) { + got := RedactURL("https://account.blob.core.windows.net/container/file?sp=r&sig=raw-sas-signature&name=visible") + + assertNotContains(t, got, "raw-sas-signature") + assertContains(t, got, "name=visible", "sig=%2A%2A%2A%2A%2A%2A") +} + +func assertContains(t *testing.T, got string, needles ...string) { + t.Helper() + for _, needle := range needles { + if !strings.Contains(got, needle) { + t.Fatalf("expected %q to contain %q", got, needle) + } + } +} + +func assertNotContains(t *testing.T, got string, needles ...string) { + t.Helper() + for _, needle := range needles { + if strings.Contains(got, needle) { + t.Fatalf("expected %q not to contain %q", got, needle) + } + } +} diff --git a/pkg/tidbcloud/v1beta1/dedicated/client.go b/pkg/tidbcloud/v1beta1/dedicated/client.go index 691b2371..c95d690a 100644 --- a/pkg/tidbcloud/v1beta1/dedicated/client.go +++ b/pkg/tidbcloud/v1beta1/dedicated/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -264,7 +265,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -277,7 +278,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/iam/client.go b/pkg/tidbcloud/v1beta1/iam/client.go index acf27158..858df8fa 100644 --- a/pkg/tidbcloud/v1beta1/iam/client.go +++ b/pkg/tidbcloud/v1beta1/iam/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/auditlog/client.go b/pkg/tidbcloud/v1beta1/serverless/auditlog/client.go index a7e70a7d..c3c94011 100644 --- a/pkg/tidbcloud/v1beta1/serverless/auditlog/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/auditlog/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/br/client.go b/pkg/tidbcloud/v1beta1/serverless/br/client.go index 6b69b015..d856c671 100644 --- a/pkg/tidbcloud/v1beta1/serverless/br/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/br/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/branch/client.go b/pkg/tidbcloud/v1beta1/serverless/branch/client.go index b5cc0434..d21d1b95 100644 --- a/pkg/tidbcloud/v1beta1/serverless/branch/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/branch/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/cdc/client.go b/pkg/tidbcloud/v1beta1/serverless/cdc/client.go index e0aea3b6..754764b2 100644 --- a/pkg/tidbcloud/v1beta1/serverless/cdc/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/cdc/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/cluster/client.go b/pkg/tidbcloud/v1beta1/serverless/cluster/client.go index 3d1587dc..6c2dda71 100644 --- a/pkg/tidbcloud/v1beta1/serverless/cluster/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/cluster/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/export/client.go b/pkg/tidbcloud/v1beta1/serverless/export/client.go index d717c0e3..79cf8942 100644 --- a/pkg/tidbcloud/v1beta1/serverless/export/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/export/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -249,7 +250,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -262,7 +263,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/imp/client.go b/pkg/tidbcloud/v1beta1/serverless/imp/client.go index bdd8d5f4..c6c4c05c 100644 --- a/pkg/tidbcloud/v1beta1/serverless/imp/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/imp/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/imp/client_debug_test.go b/pkg/tidbcloud/v1beta1/serverless/imp/client_debug_test.go new file mode 100644 index 00000000..a5a67272 --- /dev/null +++ b/pkg/tidbcloud/v1beta1/serverless/imp/client_debug_test.go @@ -0,0 +1,62 @@ +/* +TiDB Cloud Serverless Open API + +TiDB Cloud Serverless Open API + +API version: v1beta1 +*/ + +package imp + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGeneratedClientDebugRedactsSensitiveData(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Set-Cookie", "session=raw-cookie") + w.Header().Set("Content-Type", "application/json") + _, _ = io.Copy(io.Discard, r.Body) + _, _ = w.Write([]byte(`{"access_token":"raw-access-token","name":"visible"}`)) + })) + defer server.Close() + + var logs bytes.Buffer + originalWriter := log.Writer() + log.SetOutput(&logs) + defer log.SetOutput(originalWriter) + + cfg := NewConfiguration() + cfg.Debug = true + cfg.HTTPClient = server.Client() + client := NewAPIClient(cfg) + + req, err := http.NewRequest(http.MethodPost, server.URL+"/import?X-Amz-Signature=raw-signature&safe=visible", strings.NewReader(`{"secretAccessKey":"raw-secret","name":"visible"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer raw-bearer") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.callAPI(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + got := logs.String() + for _, secret := range []string{"raw-bearer", "raw-signature", "raw-secret", "raw-cookie", "raw-access-token"} { + if strings.Contains(got, secret) { + t.Fatalf("generated client debug log leaked %q: %s", secret, got) + } + } + if !strings.Contains(got, "safe=visible") || !strings.Contains(got, `"name":"visible"`) || !strings.Contains(got, "******") { + t.Fatalf("generated client debug log lost expected context or masks: %s", got) + } +} diff --git a/pkg/tidbcloud/v1beta1/serverless/migration/client.go b/pkg/tidbcloud/v1beta1/serverless/migration/client.go index a3518159..cf8a69ed 100644 --- a/pkg/tidbcloud/v1beta1/serverless/migration/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/migration/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/privatelink/client.go b/pkg/tidbcloud/v1beta1/serverless/privatelink/client.go index b8eefe99..a9e0401b 100644 --- a/pkg/tidbcloud/v1beta1/serverless/privatelink/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/privatelink/client.go @@ -21,7 +21,6 @@ import ( "log" "mime/multipart" "net/http" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -31,6 +30,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/redact" ) var ( @@ -246,7 +247,7 @@ func parameterToJson(obj interface{}) (string, error) { // callAPI do the request. func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { if c.cfg.Debug { - dump, err := httputil.DumpRequestOut(request, true) + dump, err := redact.DumpRequestOut(request, true) if err != nil { return nil, err } @@ -259,7 +260,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := redact.DumpResponse(resp, true) if err != nil { return resp, err }