From 4a123033b9ac3d55a539e4032e0956475b92ae8f Mon Sep 17 00:00:00 2001 From: Yuqing Bai Date: Wed, 13 May 2026 22:09:19 +0800 Subject: [PATCH] Prevent debug output from exposing credentials Centralize debug redaction for HTTP dumps and Resty logs while preserving request behavior. Mask config output for sensitive profile fields and disable raw debug on pre-signed transfer paths. Constraint: CLI/API behavior must remain stable; only log/stdout exposure should change. Rejected: raw Resty debug for pre-signed URL paths | callbacks cannot redact request URI query secrets. Confidence: high Scope-risk: moderate Directive: Keep generated client debug dumps routed through pkg/tidbcloud/redact after regeneration. Tested: go test ./internal/util ./internal/security; make test; (cd pkg && go test ./...); golangci-lint run ./...; make build Not-tested: live TiDB Cloud OAuth/API calls Co-authored-by: OmX --- internal/cli/auth/login.go | 2 +- internal/cli/auth/logout.go | 3 +- internal/cli/auth/whoami.go | 3 +- internal/cli/config/describe.go | 3 +- internal/cli/config/describe_test.go | 8 +- internal/cli/config/set.go | 3 +- internal/cli/config/set_test.go | 4 +- internal/cli/root_test.go | 8 +- internal/security/debug_dump_test.go | 58 +++ internal/service/aws/s3/uploader.go | 4 - .../service/aws/s3/uploader_debug_test.go | 33 ++ internal/service/cloud/api_client.go | 6 +- internal/service/cloud/api_client_test.go | 113 ++++++ internal/util/download.go | 9 +- internal/util/resty_debug.go | 41 +++ internal/util/resty_debug_test.go | 81 +++++ pkg/tidbcloud/redact/redact.go | 338 ++++++++++++++++++ pkg/tidbcloud/redact/redact_test.go | 117 ++++++ pkg/tidbcloud/v1beta1/dedicated/client.go | 7 +- pkg/tidbcloud/v1beta1/iam/client.go | 7 +- .../v1beta1/serverless/auditlog/client.go | 7 +- pkg/tidbcloud/v1beta1/serverless/br/client.go | 7 +- .../v1beta1/serverless/branch/client.go | 7 +- .../v1beta1/serverless/cdc/client.go | 7 +- .../v1beta1/serverless/cluster/client.go | 7 +- .../v1beta1/serverless/export/client.go | 7 +- .../v1beta1/serverless/imp/client.go | 7 +- .../serverless/imp/client_debug_test.go | 62 ++++ .../v1beta1/serverless/migration/client.go | 7 +- .../v1beta1/serverless/privatelink/client.go | 7 +- 30 files changed, 914 insertions(+), 59 deletions(-) create mode 100644 internal/security/debug_dump_test.go create mode 100644 internal/service/aws/s3/uploader_debug_test.go create mode 100644 internal/service/cloud/api_client_test.go create mode 100644 internal/util/resty_debug.go create mode 100644 internal/util/resty_debug_test.go create mode 100644 pkg/tidbcloud/redact/redact.go create mode 100644 pkg/tidbcloud/redact/redact_test.go create mode 100644 pkg/tidbcloud/v1beta1/serverless/imp/client_debug_test.go 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 }