diff --git a/internal/service/cloud/api_client.go b/internal/service/cloud/api_client.go index 760aba20..6222f891 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/service/cloud/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" @@ -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) 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) if err != nil { return resp, err } diff --git a/internal/service/cloud/api_client_debug_test.go b/internal/service/cloud/api_client_debug_test.go new file mode 100644 index 00000000..2dfd0054 --- /dev/null +++ b/internal/service/cloud/api_client_debug_test.go @@ -0,0 +1,208 @@ +// 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" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/tidbcloud/tidbcloud-cli/internal/config" +) + +// captureStdout swaps os.Stdout for a pipe while fn runs and returns the +// captured bytes. Not safe for concurrent use; these tests must not run in +// parallel. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + fn() + + _ = w.Close() + <-done + os.Stdout = old + _ = r.Close() + return buf.String() +} + +func TestDebugTransport_RedactsBearerToken(t *testing.T) { + t.Setenv(config.DebugEnv, "1") + + const token = "leakyBearerTokenABCDEF.payload.signature" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", "sid=responseLeakCookie; HttpOnly") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"access_token":"responseAccessTokenSecret","user":"alice"}`)) + })) + defer srv.Close() + + transport := NewBearTokenTransport(token) + client := &http.Client{Transport: transport} + + out := captureStdout(t, func() { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/v1/x", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client.Do: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }) + + if strings.Contains(out, token) { + t.Fatalf("raw bearer token leaked to stdout:\n%s", out) + } + if !strings.Contains(out, "Bearer [REDACTED]") { + t.Fatalf("expected 'Bearer [REDACTED]' in stdout, got:\n%s", out) + } + if strings.Contains(out, "responseAccessTokenSecret") { + t.Fatalf("response access_token leaked to stdout:\n%s", out) + } + if strings.Contains(out, "responseLeakCookie") { + t.Fatalf("Set-Cookie value leaked to stdout:\n%s", out) + } +} + +func TestDebugTransport_NoDebug_NoOutput(t *testing.T) { + t.Setenv(config.DebugEnv, "") + + const token = "shouldNotBePrintedToken" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := &http.Client{Transport: NewBearTokenTransport(token)} + + out := captureStdout(t, func() { + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client.Do: %v", err) + } + _ = resp.Body.Close() + }) + + if out != "" { + t.Fatalf("expected no stdout output when debug off, got %q", out) + } +} + +func TestDebugTransport_RedactsS3ImportSecret(t *testing.T) { + t.Setenv(config.DebugEnv, "1") + + const ( + token = "bearerForS3Test" + secretAccessValue = "wJalrXUtnFEMI_S3_SECRET_LEAK_CANARY" + ) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // drain the body so DumpRequestOut sees something to dump + _, _ = io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"imp-1"}`)) + })) + defer srv.Close() + + body := bytes.NewBufferString( + `{"source":{"type":"S3","s3":{"uri":"s3://b/k","accessKey":{"id":"AKIATEST","secret":"` + secretAccessValue + `"}}}}`, + ) + + client := &http.Client{Transport: NewBearTokenTransport(token)} + + out := captureStdout(t, func() { + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/v1/imports", body) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client.Do: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }) + + if strings.Contains(out, secretAccessValue) { + t.Fatalf("S3 secret leaked to stdout:\n%s", out) + } + if !strings.Contains(out, "AKIATEST") { + t.Fatalf("access key id should remain visible (it is an identifier, not a secret):\n%s", out) + } + if strings.Contains(out, token) { + t.Fatalf("bearer token leaked to stdout:\n%s", out) + } +} + +func TestDebugTransport_BodyForwardedToServer(t *testing.T) { + t.Setenv(config.DebugEnv, "1") + + const payload = `{"clusterId":"c-1","secret":"x"}` + + var received []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + received = b + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := &http.Client{Transport: NewBearTokenTransport("t")} + + captureStdout(t, func() { + req, _ := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + srv.URL+"/", + bytes.NewBufferString(payload), + ) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client.Do: %v", err) + } + _ = resp.Body.Close() + }) + + if string(received) != payload { + t.Fatalf("server received %q, want %q (debug dump must not alter wire body)", string(received), payload) + } +} diff --git a/internal/service/cloud/redact/redact.go b/internal/service/cloud/redact/redact.go new file mode 100644 index 00000000..416caa88 --- /dev/null +++ b/internal/service/cloud/redact/redact.go @@ -0,0 +1,265 @@ +// 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 provides redacted HTTP request/response dumps for debug +// logging. It is a drop-in replacement for httputil.DumpRequestOut and +// httputil.DumpResponse with body=true, but strips credential-bearing +// headers and JSON body fields before serializing. +package redact + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httputil" + "strings" +) + +// Placeholder is the string substituted in place of a redacted value. +const Placeholder = "[REDACTED]" + +// sensitiveHeaders are HTTP headers whose values must never be printed. +var sensitiveHeaders = []string{ + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie", +} + +// sensitiveJSONKeys are JSON object key names (case-insensitive) whose +// values must be replaced with the placeholder. +var sensitiveJSONKeys = map[string]struct{}{ + "secret": {}, + "secretaccesskey": {}, + "accesskeysecret": {}, + "serviceaccountkey": {}, + "sastoken": {}, + "sas_token": {}, + "private-key": {}, + "privatekey": {}, + "private_key": {}, + "oauth-client-secret": {}, + "oauthclientsecret": {}, + "oauth_client_secret": {}, + "clientsecret": {}, + "client_secret": {}, + "access-token": {}, + "access_token": {}, + "accesstoken": {}, + "refresh-token": {}, + "refresh_token": {}, + "refreshtoken": {}, + "password": {}, + "token": {}, +} + +// DumpRequestOut behaves like httputil.DumpRequestOut(r, true) but redacts +// sensitive headers and JSON body fields. The original request's headers +// and body are restored before returning so the caller can still send it. +func DumpRequestOut(r *http.Request) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("redact: nil request") + } + + origHeader := cloneHeader(r.Header) + origBody, err := drainBody(&r.Body) + if err != nil { + return nil, err + } + + // Apply redactions in place. + r.Header = cloneHeader(origHeader) + redactHeaders(r.Header) + redactedBody := redactBody(origBody, r.Header.Get("Content-Type")) + if origBody != nil { + r.Body = io.NopCloser(bytes.NewReader(redactedBody)) + r.ContentLength = int64(len(redactedBody)) + } + + dump, dumpErr := httputil.DumpRequestOut(r, true) + + // Restore original headers and body so the request can still be sent. + r.Header = origHeader + if origBody != nil { + r.Body = io.NopCloser(bytes.NewReader(origBody)) + r.ContentLength = int64(len(origBody)) + } + + return dump, dumpErr +} + +// DumpResponse behaves like httputil.DumpResponse(resp, true) but redacts +// sensitive headers and JSON body fields. The response body is restored +// before returning so the caller can still read it. +func DumpResponse(resp *http.Response) ([]byte, error) { + if resp == nil { + return nil, fmt.Errorf("redact: nil response") + } + + origHeader := cloneHeader(resp.Header) + origBody, err := drainBody(&resp.Body) + if err != nil { + return nil, err + } + + resp.Header = cloneHeader(origHeader) + redactHeaders(resp.Header) + redactedBody := redactBody(origBody, resp.Header.Get("Content-Type")) + if origBody != nil { + resp.Body = io.NopCloser(bytes.NewReader(redactedBody)) + resp.ContentLength = int64(len(redactedBody)) + } + + dump, dumpErr := httputil.DumpResponse(resp, true) + + resp.Header = origHeader + if origBody != nil { + resp.Body = io.NopCloser(bytes.NewReader(origBody)) + resp.ContentLength = int64(len(origBody)) + } + + return dump, dumpErr +} + +// redactHeaders mutates h, replacing values of sensitive headers with the +// placeholder. For Authorization-style headers the auth scheme prefix +// (Bearer / Digest / Basic) is preserved so the auth method stays visible +// in logs. +func redactHeaders(h http.Header) { + for _, name := range sensitiveHeaders { + values := h.Values(name) + if len(values) == 0 { + continue + } + h.Del(name) + for _, v := range values { + h.Add(name, redactHeaderValue(name, v)) + } + } +} + +func redactHeaderValue(name, value string) string { + if strings.EqualFold(name, "Authorization") || strings.EqualFold(name, "Proxy-Authorization") { + parts := strings.SplitN(value, " ", 2) + if len(parts) == 2 { + scheme := parts[0] + if strings.EqualFold(scheme, "Bearer") || + strings.EqualFold(scheme, "Digest") || + strings.EqualFold(scheme, "Basic") { + return scheme + " " + Placeholder + } + } + } + return Placeholder +} + +// redactBody returns a redacted copy of body. JSON bodies have sensitive +// keys replaced; non-JSON bodies are replaced with a placeholder noting +// their length, since they may contain pre-signed URLs or form-encoded +// credentials. +func redactBody(body []byte, contentType string) []byte { + if len(body) == 0 { + return body + } + + if isJSONContentType(contentType) || looksLikeJSON(body) { + var parsed interface{} + if err := json.Unmarshal(body, &parsed); err == nil { + out, marshalErr := json.Marshal(redactJSONValue(parsed)) + if marshalErr == nil { + return out + } + } + } + + return []byte(fmt.Sprintf("[REDACTED non-JSON body, %d bytes]", len(body))) +} + +func redactJSONValue(v interface{}) interface{} { + switch t := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(t)) + for k, val := range t { + if isSensitiveJSONKey(k) { + out[k] = Placeholder + } else { + out[k] = redactJSONValue(val) + } + } + return out + case []interface{}: + out := make([]interface{}, len(t)) + for i, item := range t { + out[i] = redactJSONValue(item) + } + return out + default: + return v + } +} + +func isSensitiveJSONKey(k string) bool { + _, ok := sensitiveJSONKeys[strings.ToLower(k)] + return ok +} + +func isJSONContentType(ct string) bool { + ct = strings.ToLower(ct) + return strings.HasPrefix(ct, "application/json") || strings.Contains(ct, "+json") +} + +func looksLikeJSON(body []byte) bool { + for _, b := range body { + switch b { + case ' ', '\t', '\n', '\r': + continue + case '{', '[': + return true + default: + return false + } + } + return false +} + +func cloneHeader(h http.Header) http.Header { + if h == nil { + return http.Header{} + } + out := make(http.Header, len(h)) + for k, v := range h { + dup := make([]string, len(v)) + copy(dup, v) + out[k] = dup + } + return out +} + +// drainBody fully reads *bodyPtr (if non-nil), closes the original, and +// returns the bytes. *bodyPtr is left as nil; the caller is responsible +// for setting a fresh reader. +func drainBody(bodyPtr *io.ReadCloser) ([]byte, error) { + if bodyPtr == nil || *bodyPtr == nil || *bodyPtr == http.NoBody { + return nil, nil + } + b, err := io.ReadAll(*bodyPtr) + if err != nil { + return nil, err + } + _ = (*bodyPtr).Close() + *bodyPtr = nil + return b, nil +} diff --git a/internal/service/cloud/redact/redact_test.go b/internal/service/cloud/redact/redact_test.go new file mode 100644 index 00000000..223e8e7b --- /dev/null +++ b/internal/service/cloud/redact/redact_test.go @@ -0,0 +1,378 @@ +// 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" + "io" + "net/http" + "strings" + "testing" +) + +const ( + bearerSecret = "eyJhbGciOiJIUzI1NiJ9.tokenpayload.signature123" + digestRaw = `Digest username="pub", realm="r", nonce="n", uri="/x", response="abcdef0123456789", qop=auth, nc=00000001, cnonce="c"` + awsSecretAccess = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + ossSecretAccess = "ossSecretValue12345" + awsAccessKeyID = "AKIAIOSFODNN7EXAMPLE" + gcsServiceAccount = `{"type":"service_account","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvA...\n-----END PRIVATE KEY-----"}` + azureSAS = "sv=2024-01-01&sig=abc123XYZ&se=2030-01-01" +) + +func newRequest(t *testing.T, method, url, contentType string, body []byte) *http.Request { + t.Helper() + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + r, err := http.NewRequest(method, url, bodyReader) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + if contentType != "" { + r.Header.Set("Content-Type", contentType) + } + return r +} + +func TestDumpRequestOut_BearerToken(t *testing.T) { + r := newRequest(t, http.MethodGet, "https://example.com/v1/clusters", "", nil) + r.Header.Set("Authorization", "Bearer "+bearerSecret) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + s := string(out) + if strings.Contains(s, bearerSecret) { + t.Fatalf("raw bearer token leaked into dump:\n%s", s) + } + if !strings.Contains(s, "Bearer "+Placeholder) { + t.Fatalf("expected 'Bearer %s' in dump, got:\n%s", Placeholder, s) + } + // Original header must be restored on the request so it can still be sent. + if r.Header.Get("Authorization") != "Bearer "+bearerSecret { + t.Fatalf("original Authorization header was not restored: %q", r.Header.Get("Authorization")) + } +} + +func TestDumpRequestOut_DigestAuth(t *testing.T) { + r := newRequest(t, http.MethodGet, "https://example.com/v1/x", "", nil) + r.Header.Set("Authorization", digestRaw) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + s := string(out) + if strings.Contains(s, "response=\"abcdef") { + t.Fatalf("digest response leaked:\n%s", s) + } + if !strings.Contains(s, "Digest "+Placeholder) { + t.Fatalf("expected 'Digest %s' in dump, got:\n%s", Placeholder, s) + } +} + +func TestDumpRequestOut_CookieAndProxyAuth(t *testing.T) { + r := newRequest(t, http.MethodGet, "https://example.com/", "", nil) + r.Header.Set("Cookie", "session=supersecretcookievalue") + r.Header.Set("Proxy-Authorization", "Basic dXNlcjpwYXNz") + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + s := string(out) + if strings.Contains(s, "supersecretcookievalue") { + t.Fatalf("cookie value leaked:\n%s", s) + } + if strings.Contains(s, "dXNlcjpwYXNz") { + t.Fatalf("proxy basic creds leaked:\n%s", s) + } + if !strings.Contains(s, "Basic "+Placeholder) { + t.Fatalf("expected 'Basic %s', got:\n%s", Placeholder, s) + } +} + +func TestDumpRequestOut_S3ImportBody(t *testing.T) { + body := []byte(`{"source":{"type":"S3","s3":{"uri":"s3://bucket/k","accessKey":{"id":"` + awsAccessKeyID + `","secret":"` + awsSecretAccess + `"}}}}`) + r := newRequest(t, http.MethodPost, "https://example.com/v1/imports", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + s := string(out) + if strings.Contains(s, awsSecretAccess) { + t.Fatalf("S3 secret leaked:\n%s", s) + } + // Identifier (access key id) is not a secret per the report; should remain visible. + if !strings.Contains(s, awsAccessKeyID) { + t.Fatalf("expected access key id %q in dump, got:\n%s", awsAccessKeyID, s) + } +} + +func TestDumpRequestOut_OSSImportBody(t *testing.T) { + body := []byte(`{"source":{"oss":{"accessKey":{"id":"LTAI...","secret":"` + ossSecretAccess + `"}}}}`) + r := newRequest(t, http.MethodPost, "https://example.com/v1/imports", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + if strings.Contains(string(out), ossSecretAccess) { + t.Fatalf("OSS secret leaked:\n%s", string(out)) + } +} + +func TestDumpRequestOut_ExportS3Body(t *testing.T) { + body := []byte(`{"target":{"s3":{"uri":"s3://b/p","accessKey":{"id":"AKIA","secret":"` + awsSecretAccess + `"}}}}`) + r := newRequest(t, http.MethodPost, "https://example.com/v1/exports", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + if strings.Contains(string(out), awsSecretAccess) { + t.Fatalf("export S3 secret leaked:\n%s", string(out)) + } +} + +func TestDumpRequestOut_GCSServiceAccountKey(t *testing.T) { + saKey, _ := json.Marshal(gcsServiceAccount) + body := []byte(`{"target":{"gcs":{"uri":"gs://b/p","serviceAccountKey":` + string(saKey) + `}}}`) + r := newRequest(t, http.MethodPost, "https://example.com/v1/exports", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + if strings.Contains(string(out), "BEGIN PRIVATE KEY") { + t.Fatalf("GCS private key leaked:\n%s", string(out)) + } + if strings.Contains(string(out), "service_account") { + t.Fatalf("GCS service account JSON leaked:\n%s", string(out)) + } +} + +func TestDumpRequestOut_AzureSasToken(t *testing.T) { + body := []byte(`{"target":{"azureBlob":{"uri":"https://a.blob.core.windows.net/c/p","sasToken":"` + azureSAS + `"}}}`) + r := newRequest(t, http.MethodPost, "https://example.com/v1/exports", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + if strings.Contains(string(out), azureSAS) { + t.Fatalf("Azure SAS token leaked:\n%s", string(out)) + } +} + +func TestDumpRequestOut_AuditLogStorageBody(t *testing.T) { + body := []byte(`{"auditLogConfig":{"cloudStorage":{"s3":{"accessKey":{"id":"AKIA","secret":"` + awsSecretAccess + `"}}}}}`) + r := newRequest(t, http.MethodPatch, "https://example.com/v1/clusters/x/auditLogConfig", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + if strings.Contains(string(out), awsSecretAccess) { + t.Fatalf("audit log S3 secret leaked:\n%s", string(out)) + } +} + +func TestDumpRequestOut_NestedJSONSecret(t *testing.T) { + body := []byte(`{"a":{"b":{"c":{"secret":"deeplyNestedSecret"}}}}`) + r := newRequest(t, http.MethodPost, "https://example.com/", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + if strings.Contains(string(out), "deeplyNestedSecret") { + t.Fatalf("nested secret leaked:\n%s", string(out)) + } +} + +func TestDumpRequestOut_JSONArrayWithSecrets(t *testing.T) { + body := []byte(`{"users":[{"name":"u1","password":"p1secret"},{"name":"u2","password":"p2secret"}]}`) + r := newRequest(t, http.MethodPost, "https://example.com/", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + s := string(out) + if strings.Contains(s, "p1secret") || strings.Contains(s, "p2secret") { + t.Fatalf("password leaked from array:\n%s", s) + } + if !strings.Contains(s, `"u1"`) || !strings.Contains(s, `"u2"`) { + t.Fatalf("non-sensitive fields lost:\n%s", s) + } +} + +func TestDumpRequestOut_NonJSONBody(t *testing.T) { + body := []byte("raw=upload&signature=abc123XYZdangerous") + r := newRequest(t, http.MethodPost, "https://example.com/", "application/x-www-form-urlencoded", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + s := string(out) + if strings.Contains(s, "abc123XYZdangerous") { + t.Fatalf("non-JSON body content leaked:\n%s", s) + } + if !strings.Contains(s, "REDACTED non-JSON body") { + t.Fatalf("expected non-JSON placeholder, got:\n%s", s) + } +} + +func TestDumpRequestOut_EmptyBody(t *testing.T) { + r := newRequest(t, http.MethodGet, "https://example.com/v1/clusters", "", nil) + r.Header.Set("Authorization", "Bearer "+bearerSecret) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + if strings.Contains(string(out), bearerSecret) { + t.Fatalf("token leaked on empty-body request:\n%s", string(out)) + } +} + +func TestDumpRequestOut_BodyStillReadable(t *testing.T) { + original := []byte(`{"name":"x","secret":"y"}`) + r := newRequest(t, http.MethodPost, "https://example.com/", "application/json", original) + + if _, err := DumpRequestOut(r); err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + got, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read restored body: %v", err) + } + if !bytes.Equal(got, original) { + t.Fatalf("restored body mismatch: got %q want %q", got, original) + } +} + +func TestDumpRequestOut_PreservesNonSecretFields(t *testing.T) { + body := []byte(`{"clusterId":"cluster-123","displayName":"my-cluster","region":"us-east-1","secret":"hidden"}`) + r := newRequest(t, http.MethodPost, "https://example.com/", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + s := string(out) + for _, want := range []string{"cluster-123", "my-cluster", "us-east-1"} { + if !strings.Contains(s, want) { + t.Fatalf("expected %q in dump, got:\n%s", want, s) + } + } + if strings.Contains(s, "hidden") { + t.Fatalf("secret leaked alongside non-secret fields:\n%s", s) + } +} + +func TestDumpRequestOut_CaseInsensitiveJSONKey(t *testing.T) { + body := []byte(`{"Secret":"upperCaseSecret","SecretAccessKey":"mixedCaseSecret","ACCESS_TOKEN":"shoutingSecret"}`) + r := newRequest(t, http.MethodPost, "https://example.com/", "application/json", body) + + out, err := DumpRequestOut(r) + if err != nil { + t.Fatalf("DumpRequestOut: %v", err) + } + s := string(out) + for _, leak := range []string{"upperCaseSecret", "mixedCaseSecret", "shoutingSecret"} { + if strings.Contains(s, leak) { + t.Fatalf("%s leaked despite case-insensitive match:\n%s", leak, s) + } + } +} + +func TestDumpResponse_SetCookieRedacted(t *testing.T) { + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{}, + Body: io.NopCloser(bytes.NewReader([]byte(`{"ok":true}`))), + } + resp.Header.Set("Set-Cookie", "sid=responseCookieValue; HttpOnly") + resp.Header.Set("Content-Type", "application/json") + + out, err := DumpResponse(resp) + if err != nil { + t.Fatalf("DumpResponse: %v", err) + } + if strings.Contains(string(out), "responseCookieValue") { + t.Fatalf("Set-Cookie value leaked:\n%s", string(out)) + } +} + +func TestDumpResponse_JSONBodyRedacted(t *testing.T) { + body := []byte(`{"access_token":"shouldNotLeak","refresh_token":"alsoSecret","user":"alice"}`) + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + } + + out, err := DumpResponse(resp) + if err != nil { + t.Fatalf("DumpResponse: %v", err) + } + s := string(out) + if strings.Contains(s, "shouldNotLeak") || strings.Contains(s, "alsoSecret") { + t.Fatalf("token leaked in response body:\n%s", s) + } + if !strings.Contains(s, "alice") { + t.Fatalf("non-sensitive 'user' lost:\n%s", s) + } +} + +func TestDumpResponse_BodyStillReadable(t *testing.T) { + original := []byte(`{"access_token":"x","user":"alice"}`) + resp := &http.Response{ + Status: "200 OK", + StatusCode: 200, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(original)), + } + + if _, err := DumpResponse(resp); err != nil { + t.Fatalf("DumpResponse: %v", err) + } + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read restored body: %v", err) + } + if !bytes.Equal(got, original) { + t.Fatalf("restored body mismatch: got %q want %q", got, original) + } +}