From be2732d1da2fb1b5f598275aa742f0c9845e622a Mon Sep 17 00:00:00 2001 From: Yuqing Bai Date: Tue, 15 Sep 2026 18:07:44 +0800 Subject: [PATCH 1/3] Mask credentials in debug output and config commands Debug mode (--debug / TICLOUD_DEBUG) dumped full HTTP requests and responses, including the Authorization header, OAuth access/refresh tokens, the OAuth client secret, cloud storage access keys and SAS tokens in import/export/audit-log request bodies, and pre-signed URL signatures. config describe printed private-key, oauth-client-secret and access-token, and config set echoed the value of private-key and oauth-client-secret. Add internal/redact with one masking implementation for headers, JSON body fields, pre-signed URL query parameters and profile properties, plus a DebugTransport that prints masked dumps. Non-JSON bodies (file uploads, export downloads) are not read; only their size is printed. Route every debug path through it: - cloud.NewDebugTransport (Bearer and Digest API clients) - resty clients in auth login/logout/whoami, the S3 uploader and the export download helper (replaces resty SetDebug) - config describe masks private-key, oauth-client-secret, access-token - config set prints *** for private-key and oauth-client-secret - generated v1beta1 clients no longer include bodies in the cfg.Debug dump Add internal/redact tests that assert a bearer token, cloud AKSK, SAS token, pre-signed URL signature and Set-Cookie value do not appear in the dump while request and response bodies remain readable. --- internal/cli/auth/login.go | 3 +- internal/cli/auth/logout.go | 3 +- internal/cli/auth/whoami.go | 3 +- internal/cli/config/describe.go | 3 +- internal/cli/config/describe_test.go | 6 +- internal/cli/config/set.go | 3 +- internal/cli/config/set_test.go | 4 +- internal/cli/root_test.go | 8 +- internal/redact/redact.go | 229 ++++++++++++++++++ internal/redact/redact_test.go | 93 +++++++ internal/service/aws/s3/uploader.go | 3 +- internal/service/cloud/api_client.go | 36 +-- internal/util/download.go | 4 +- pkg/tidbcloud/v1beta1/dedicated/client.go | 4 +- pkg/tidbcloud/v1beta1/iam/client.go | 4 +- .../v1beta1/serverless/auditlog/client.go | 4 +- pkg/tidbcloud/v1beta1/serverless/br/client.go | 4 +- .../v1beta1/serverless/branch/client.go | 4 +- .../v1beta1/serverless/cdc/client.go | 4 +- .../v1beta1/serverless/cluster/client.go | 4 +- .../v1beta1/serverless/export/client.go | 4 +- .../v1beta1/serverless/imp/client.go | 4 +- .../v1beta1/serverless/migration/client.go | 4 +- .../v1beta1/serverless/privatelink/client.go | 4 +- 24 files changed, 372 insertions(+), 70 deletions(-) create mode 100644 internal/redact/redact.go create mode 100644 internal/redact/redact_test.go diff --git a/internal/cli/auth/login.go b/internal/cli/auth/login.go index 8692e7f0..685623a5 100644 --- a/internal/cli/auth/login.go +++ b/internal/cli/auth/login.go @@ -27,6 +27,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/redact" "github.com/tidbcloud/tidbcloud-cli/internal/util" ver "github.com/tidbcloud/tidbcloud-cli/internal/version" @@ -58,7 +59,7 @@ func LoginCmd(h *internal.Helper) *cobra.Command { if err != nil { return err } - opts.client.SetDebug(debug) + opts.client.SetTransport(redact.NewDebugTransport(opts.client.GetClient().Transport, 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..ebe6d822 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/redact" 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) + opts.client.SetTransport(redact.NewDebugTransport(opts.client.GetClient().Transport, 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..cd814302 100644 --- a/internal/cli/auth/whoami.go +++ b/internal/cli/auth/whoami.go @@ -25,6 +25,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/redact" "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" ver "github.com/tidbcloud/tidbcloud-cli/internal/version" @@ -56,7 +57,7 @@ func WhoamiCmd(h *internal.Helper) *cobra.Command { if err != nil { return err } - opts.client.SetDebug(debug) + opts.client.SetTransport(redact.NewDebugTransport(opts.client.GetClient().Transport, 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..2d7b4180 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/internal/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.Any(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..05b9082e 100644 --- a/internal/cli/config/describe_test.go +++ b/internal/cli/config/describe_test.go @@ -81,12 +81,12 @@ func (suite *DescribeConfigSuite) TestDescribeConfigArgs() { { name: "describe config", args: []string{"test"}, - stdoutString: "{\n \"private-key\": \"SDWIOUEOSDSDC\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", + stdoutString: "{\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 \"private-key\": \"***\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", }, { name: "describe config with no args", @@ -143,7 +143,7 @@ func (suite *DescribeConfigSuite) TestDescribeConfigWithSpecialCharacters() { { name: "describe active profile", args: []string{"~`!@#$%^&*()_+-={}[]\\|;:,<>/?"}, - stdoutString: "{\n \"private-key\": \"SDWIOUEOSDSDC\",\n \"public-key\": \"SDIWODIJQNDKJQW\"\n}\n", + stdoutString: "{\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..654cccd8 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/internal/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.Value(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..d66ba74c 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..9a3d9b9c 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/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 00000000..63f502d7 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,229 @@ +// 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 masks credentials in values that are printed for debugging +// (HTTP dumps, profile output) so that debug mode does not leak secrets. +package redact + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const Mask = "***" + +// normalize lower-cases a key and strips "-" and "_" so that +// "secret_access_key", "secretAccessKey" and "secret-access-key" all compare equal. +func normalize(key string) string { + return strings.NewReplacer("-", "", "_", "").Replace(strings.ToLower(key)) +} + +var sensitiveKeys = map[string]struct{}{ + "secret": {}, + "secretaccesskey": {}, + "accesskeysecret": {}, + "serviceaccountkey": {}, + "sastoken": {}, + "azuresastoken": {}, + "privatekey": {}, + "oauthclientsecret": {}, + "clientsecret": {}, + "accesstoken": {}, + "refreshtoken": {}, + "token": {}, + "password": {}, + "rootpassword": {}, + "sslkeycontent": {}, +} + +var sensitiveHeaders = map[string]struct{}{ + "authorization": {}, + "proxy-authorization": {}, + "cookie": {}, + "set-cookie": {}, + "x-amz-security-token": {}, +} + +// IsSensitiveKey reports whether a JSON field, profile property or query +// parameter name holds a credential. +func IsSensitiveKey(key string) bool { + _, ok := sensitiveKeys[normalize(key)] + return ok +} + +// isSensitiveQueryParam covers pre-signed URL parameters of S3, GCS, Azure and OSS. +func isSensitiveQueryParam(key string) bool { + k := normalize(key) + if IsSensitiveKey(k) || k == "sig" || k == "ossaccesskeyid" { + return true + } + for _, suffix := range []string{"signature", "credential", "securitytoken"} { + if strings.HasSuffix(k, suffix) { + return true + } + } + return false +} + +// Value returns Mask when key is sensitive, otherwise value unchanged. +func Value(key, value string) string { + if IsSensitiveKey(key) { + return Mask + } + return value +} + +// Any recursively masks sensitive keys in decoded JSON / viper values. +func Any(v interface{}) interface{} { + switch t := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(t)) + for k, val := range t { + if IsSensitiveKey(k) { + out[k] = Mask + } else { + out[k] = Any(val) + } + } + return out + case []interface{}: + out := make([]interface{}, len(t)) + for i, val := range t { + out[i] = Any(val) + } + return out + default: + return v + } +} + +// JSON masks sensitive fields of a JSON body. Non-JSON bodies are not echoed. +func JSON(body []byte) string { + var v interface{} + if err := json.Unmarshal(body, &v); err != nil { + return fmt.Sprintf("", len(body)) + } + out, err := json.Marshal(Any(v)) + if err != nil { + return fmt.Sprintf("", len(body)) + } + return string(out) +} + +// Headers returns a copy of h with credential-bearing headers masked. +func Headers(h http.Header) http.Header { + out := h.Clone() + for k := range out { + if _, ok := sensitiveHeaders[strings.ToLower(k)]; ok { + out[k] = []string{Mask} + } + } + return out +} + +// URL returns u as a string with credential-bearing query parameters masked. +func URL(u *url.URL) string { + if u == nil { + return "" + } + if u.RawQuery == "" { + return u.String() + } + q := u.Query() + for k := range q { + if isSensitiveQueryParam(k) { + q[k] = []string{Mask} + } + } + c := *u + c.RawQuery = q.Encode() + return c.String() +} + +// body echoes a JSON body (masked) and replaces *rc so it can still be read. +// Non-JSON bodies (file uploads, downloads) are never read. +func body(rc *io.ReadCloser, contentType string, contentLength int64) string { + if rc == nil || *rc == nil || *rc == http.NoBody { + return "" + } + if !strings.Contains(strings.ToLower(contentType), "json") { + return fmt.Sprintf("<%s body omitted, %d bytes>", contentType, contentLength) + } + data, err := io.ReadAll(*rc) + (*rc).Close() + *rc = io.NopCloser(bytes.NewReader(data)) + if err != nil { + return fmt.Sprintf("", err) + } + return JSON(data) +} + +// DumpRequest renders r with credentials masked. +func DumpRequest(r *http.Request) string { + var b strings.Builder + fmt.Fprintf(&b, "%s %s %s\n", r.Method, URL(r.URL), r.Proto) + host := r.Host + if host == "" && r.URL != nil { + host = r.URL.Host + } + fmt.Fprintf(&b, "Host: %s\n", host) + _ = Headers(r.Header).Write(&b) + b.WriteString("\n") + b.WriteString(body(&r.Body, r.Header.Get("Content-Type"), r.ContentLength)) + b.WriteString("\n") + return b.String() +} + +// DumpResponse renders resp with credentials masked. +func DumpResponse(resp *http.Response) string { + var b strings.Builder + fmt.Fprintf(&b, "%s %s\n", resp.Proto, resp.Status) + _ = Headers(resp.Header).Write(&b) + b.WriteString("\n") + b.WriteString(body(&resp.Body, resp.Header.Get("Content-Type"), resp.ContentLength)) + b.WriteString("\n") + return b.String() +} + +// DebugTransport prints masked request/response dumps to stdout when Debug is set. +type DebugTransport struct { + Inner http.RoundTripper + Debug bool +} + +func NewDebugTransport(inner http.RoundTripper, debug bool) http.RoundTripper { + if inner == nil { + inner = http.DefaultTransport + } + return &DebugTransport{Inner: inner, Debug: debug} +} + +func (dt *DebugTransport) RoundTrip(r *http.Request) (*http.Response, error) { + if dt.Debug { + fmt.Printf("\n%s", DumpRequest(r)) + } + resp, err := dt.Inner.RoundTrip(r) + if err != nil { + return resp, err + } + if dt.Debug { + fmt.Printf("%s\n", DumpResponse(resp)) + } + return resp, err +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 00000000..e2a613d4 --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,93 @@ +// 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" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDumpMasksCredentials(t *testing.T) { + assert := require.New(t) + const ( + bearer = "BEARER_SECRET_TOKEN" + aksk = "AKSK_SECRET" + sig = "PRESIGNED_SIG" + cookie = "SESSION_COOKIE" + ) + + var received []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received, _ = io.ReadAll(r.Body) + w.Header().Set("Set-Cookie", "s="+cookie) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"` + bearer + `","refresh_token":"` + bearer + `","name":"me"}`)) + })) + defer srv.Close() + + var out bytes.Buffer + body := `{"target":{"s3":{"uri":"s3://b","accessKey":{"id":"AKIA","secret":"` + aksk + `"}}},"password":"` + aksk + `","sasToken":"` + aksk + `"}` + req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1?X-Amz-Signature="+sig+"&X-Amz-Credential="+sig+"&sig="+sig+"&plain=1", strings.NewReader(body)) + assert.NoError(err) + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("Content-Type", "application/json") + + out.WriteString(DumpRequest(req)) + resp, err := http.DefaultTransport.RoundTrip(req) + assert.NoError(err) + out.WriteString(DumpResponse(resp)) + + dump := out.String() + for _, secret := range []string{bearer, aksk, sig, cookie} { + assert.NotContains(dump, secret) + } + assert.Contains(dump, `"id":"AKIA"`) + assert.Contains(dump, "plain=1") + assert.Contains(dump, `"name":"me"`) + + // bodies must still be readable by the caller after dumping + assert.Equal(body, string(received)) + got, _ := io.ReadAll(resp.Body) + assert.Contains(string(got), bearer) +} + +func TestNonJSONBodyIsNotEchoed(t *testing.T) { + req, _ := http.NewRequest(http.MethodPut, "https://s3/x?X-Amz-Signature=abc", strings.NewReader("FILECONTENT")) + req.Header.Set("Content-Type", "application/octet-stream") + dump := DumpRequest(req) + require.NotContains(t, dump, "FILECONTENT") + require.NotContains(t, dump, "abc") + sent, _ := io.ReadAll(req.Body) + require.Equal(t, "FILECONTENT", string(sent)) +} + +func TestValueAndAny(t *testing.T) { + assert := require.New(t) + assert.Equal(Mask, Value("private-key", "x")) + assert.Equal(Mask, Value("oauth-client-secret", "x")) + assert.Equal("x", Value("public-key", "x")) + assert.Equal("x", Value("serverless-endpoint", "x")) + got := Any(map[string]interface{}{"public-key": "pk", "private-key": "sk", "access-token": "tk", "token-type": "Bearer"}) + assert.Equal(map[string]interface{}{"public-key": "pk", "private-key": Mask, "access-token": Mask, "token-type": "Bearer"}, got) + u, _ := url.Parse("https://h/p?a=1") + assert.Equal("https://h/p?a=1", URL(u)) +} diff --git a/internal/service/aws/s3/uploader.go b/internal/service/aws/s3/uploader.go index c1fcfaee..c720eb98 100644 --- a/internal/service/aws/s3/uploader.go +++ b/internal/service/aws/s3/uploader.go @@ -25,6 +25,7 @@ import ( "sync" "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/redact" "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" @@ -164,7 +165,7 @@ type UploaderImpl struct { func NewUploader(client cloud.TiDBCloudClient) Uploader { httpClient := resty.New() debug := os.Getenv(config.DebugEnv) != "" - httpClient.SetDebug(debug) + httpClient.SetTransport(redact.NewDebugTransport(httpClient.GetClient().Transport, debug)) u := &UploaderImpl{ PartSize: DefaultUploadPartSize, Concurrency: DefaultUploadConcurrency, diff --git a/internal/service/cloud/api_client.go b/internal/service/cloud/api_client.go index 760aba20..e2971da5 100644 --- a/internal/service/cloud/api_client.go +++ b/internal/service/cloud/api_client.go @@ -20,11 +20,11 @@ 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/redact" "github.com/tidbcloud/tidbcloud-cli/internal/version" "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/iam" "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/auditlog" @@ -842,39 +842,11 @@ func (bt *BearTokenTransport) RoundTrip(r *http.Request) (*http.Response, error) return bt.inner.RoundTrip(r) } +// NewDebugTransport wraps inner with a transport that, when TICLOUD_DEBUG is +// set, prints request/response dumps with credentials masked. func NewDebugTransport(inner http.RoundTripper) http.RoundTripper { - return &DebugTransport{inner: inner} -} - -type DebugTransport struct { - inner http.RoundTripper -} - -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) - if err != nil { - return nil, err - } - fmt.Printf("\n%s", string(dump)) - } - - resp, err := dt.inner.RoundTrip(r) - if err != nil { - return resp, err - } - - if debug { - dump, err := httputil.DumpResponse(resp, true) - if err != nil { - return resp, err - } - fmt.Printf("%s\n", string(dump)) - } - - return resp, err + return redact.NewDebugTransport(inner, debug) } func parseError(err error, resp *http.Response) error { diff --git a/internal/util/download.go b/internal/util/download.go index acb579e6..31ab84a0 100644 --- a/internal/util/download.go +++ b/internal/util/download.go @@ -25,13 +25,15 @@ import ( "strings" "unicode" + "github.com/tidbcloud/tidbcloud-cli/internal/redact" + "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) + httpClient.SetTransport(redact.NewDebugTransport(httpClient.GetClient().Transport, debug)) resp, err := httpClient.GetClient().Get(url) // nolint:gosec if err != nil { return nil, err diff --git a/pkg/tidbcloud/v1beta1/dedicated/client.go b/pkg/tidbcloud/v1beta1/dedicated/client.go index 691b2371..01b1155f 100644 --- a/pkg/tidbcloud/v1beta1/dedicated/client.go +++ b/pkg/tidbcloud/v1beta1/dedicated/client.go @@ -264,7 +264,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -277,7 +277,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/iam/client.go b/pkg/tidbcloud/v1beta1/iam/client.go index acf27158..55dbd89f 100644 --- a/pkg/tidbcloud/v1beta1/iam/client.go +++ b/pkg/tidbcloud/v1beta1/iam/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) 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..2cf1991b 100644 --- a/pkg/tidbcloud/v1beta1/serverless/auditlog/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/auditlog/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) 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..7f39cd41 100644 --- a/pkg/tidbcloud/v1beta1/serverless/br/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/br/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) 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..870a86d6 100644 --- a/pkg/tidbcloud/v1beta1/serverless/branch/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/branch/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) 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..1e50a448 100644 --- a/pkg/tidbcloud/v1beta1/serverless/cdc/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/cdc/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) 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..136a43d2 100644 --- a/pkg/tidbcloud/v1beta1/serverless/cluster/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/cluster/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) 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..910b3da1 100644 --- a/pkg/tidbcloud/v1beta1/serverless/export/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/export/client.go @@ -249,7 +249,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -262,7 +262,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) 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..a1124770 100644 --- a/pkg/tidbcloud/v1beta1/serverless/imp/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/imp/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) if err != nil { return resp, err } diff --git a/pkg/tidbcloud/v1beta1/serverless/migration/client.go b/pkg/tidbcloud/v1beta1/serverless/migration/client.go index a3518159..9db592b0 100644 --- a/pkg/tidbcloud/v1beta1/serverless/migration/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/migration/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) 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..41eda9df 100644 --- a/pkg/tidbcloud/v1beta1/serverless/privatelink/client.go +++ b/pkg/tidbcloud/v1beta1/serverless/privatelink/client.go @@ -246,7 +246,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 := httputil.DumpRequestOut(request, false) if err != nil { return nil, err } @@ -259,7 +259,7 @@ func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { } if c.cfg.Debug { - dump, err := httputil.DumpResponse(resp, true) + dump, err := httputil.DumpResponse(resp, false) if err != nil { return resp, err } From 7455fda23dfc355838db1e8297db93506e2aefb0 Mon Sep 17 00:00:00 2001 From: Yuqing Bai Date: Tue, 15 Sep 2026 18:11:30 +0800 Subject: [PATCH 2/3] Close response body in redact test --- internal/redact/redact_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go index e2a613d4..67f0d315 100644 --- a/internal/redact/redact_test.go +++ b/internal/redact/redact_test.go @@ -54,6 +54,7 @@ func TestDumpMasksCredentials(t *testing.T) { out.WriteString(DumpRequest(req)) resp, err := http.DefaultTransport.RoundTrip(req) assert.NoError(err) + defer resp.Body.Close() out.WriteString(DumpResponse(resp)) dump := out.String() From 9d7f1bb6608d4b0ca5313564eab2d3ac8e3084ae Mon Sep 17 00:00:00 2001 From: Yuqing Bai Date: Tue, 15 Sep 2026 18:26:14 +0800 Subject: [PATCH 3/3] Mask device_code and URL userinfo password in debug output --- internal/redact/redact.go | 21 +++++++++++---------- internal/redact/redact_test.go | 9 +++++++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/internal/redact/redact.go b/internal/redact/redact.go index 63f502d7..492afb93 100644 --- a/internal/redact/redact.go +++ b/internal/redact/redact.go @@ -45,6 +45,7 @@ var sensitiveKeys = map[string]struct{}{ "oauthclientsecret": {}, "clientsecret": {}, "accesstoken": {}, + "devicecode": {}, "refreshtoken": {}, "token": {}, "password": {}, @@ -142,18 +143,18 @@ func URL(u *url.URL) string { if u == nil { return "" } - if u.RawQuery == "" { - return u.String() - } - q := u.Query() - for k := range q { - if isSensitiveQueryParam(k) { - q[k] = []string{Mask} + c := *u + if c.RawQuery != "" { + q := c.Query() + for k := range q { + if isSensitiveQueryParam(k) { + q[k] = []string{Mask} + } } + c.RawQuery = q.Encode() } - c := *u - c.RawQuery = q.Encode() - return c.String() + // Redacted replaces a userinfo password with "xxxxx". + return c.Redacted() } // body echoes a JSON body (masked) and replaces *rc so it can still be read. diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go index 67f0d315..5d891c56 100644 --- a/internal/redact/redact_test.go +++ b/internal/redact/redact_test.go @@ -40,12 +40,12 @@ func TestDumpMasksCredentials(t *testing.T) { received, _ = io.ReadAll(r.Body) w.Header().Set("Set-Cookie", "s="+cookie) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"access_token":"` + bearer + `","refresh_token":"` + bearer + `","name":"me"}`)) + _, _ = w.Write([]byte(`{"access_token":"` + bearer + `","refresh_token":"` + bearer + `","device_code":"` + bearer + `","user_code":"ABCD-EFGH","name":"me"}`)) })) defer srv.Close() var out bytes.Buffer - body := `{"target":{"s3":{"uri":"s3://b","accessKey":{"id":"AKIA","secret":"` + aksk + `"}}},"password":"` + aksk + `","sasToken":"` + aksk + `"}` + body := `{"target":{"s3":{"uri":"s3://b","accessKey":{"id":"AKIA","secret":"` + aksk + `"}}},"password":"` + aksk + `","sasToken":"` + aksk + `","device_code":"` + aksk + `"}` req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1?X-Amz-Signature="+sig+"&X-Amz-Credential="+sig+"&sig="+sig+"&plain=1", strings.NewReader(body)) assert.NoError(err) req.Header.Set("Authorization", "Bearer "+bearer) @@ -64,6 +64,7 @@ func TestDumpMasksCredentials(t *testing.T) { assert.Contains(dump, `"id":"AKIA"`) assert.Contains(dump, "plain=1") assert.Contains(dump, `"name":"me"`) + assert.Contains(dump, `"user_code":"ABCD-EFGH"`) // bodies must still be readable by the caller after dumping assert.Equal(body, string(received)) @@ -91,4 +92,8 @@ func TestValueAndAny(t *testing.T) { assert.Equal(map[string]interface{}{"public-key": "pk", "private-key": Mask, "access-token": Mask, "token-type": "Bearer"}, got) u, _ := url.Parse("https://h/p?a=1") assert.Equal("https://h/p?a=1", URL(u)) + u, _ = url.Parse("https://alice:USERINFO_PASS@h/p") + assert.Equal("https://alice:xxxxx@h/p", URL(u)) + u, _ = url.Parse("https://alice@h/p?a=1") + assert.Equal("https://alice@h/p?a=1", URL(u)) }