From 14fdce4896acc9eaba9f27190d808a77c399b1b8 Mon Sep 17 00:00:00 2001 From: Jaideep Padhye Date: Mon, 3 Aug 2026 22:34:44 -0700 Subject: [PATCH] SYN-6783: redact sensitive fields in response and error debug details RequestDetails.ResponseBody and error responses from makePublicAPICall previously passed sensitive API response fields (secrets, certificate content, passwords) through with no redaction. Add a sanitized copy of the response body for debug purposes and redact the error path's Details map, while leaving the raw ResponseBody untouched so existing parse*Response call sites keep unmarshaling real values into typed structs and Terraform state. Co-Authored-By: Claude --- syntheticsclientv2/synthetics.go | 46 ++++++++++- syntheticsclientv2/synthetics_test.go | 108 ++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/syntheticsclientv2/synthetics.go b/syntheticsclientv2/synthetics.go index 37d93ac..3db3860 100644 --- a/syntheticsclientv2/synthetics.go +++ b/syntheticsclientv2/synthetics.go @@ -38,8 +38,15 @@ type ClientArgs struct { } type RequestDetails struct { - StatusCode int + StatusCode int + // ResponseBody is the raw, unredacted API response body and must be used + // for parsing responses into typed structs. ResponseBody string + // SanitizedResponseBody is the supported debug representation of the + // response body. It redacts sensitive JSON values before being returned + // and must be used instead of ResponseBody for logging or persisting + // debug details. + SanitizedResponseBody string // RequestBody is the supported debug representation of the outgoing request. // It redacts API tokens and sensitive JSON values before being returned. RequestBody string @@ -119,6 +126,7 @@ func (c Client) makePublicAPICall(method string, endpoint string, requestBody io if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { var errRes errorResponse if err = json.NewDecoder(resp.Body).Decode(&errRes); err == nil { + redactErrorResponseFields(&errRes) errorField, err2 := json.Marshal(errRes) if err2 != nil { return &details, fmt.Errorf("unknown issue while parsing API error response, status code: %d", resp.StatusCode) @@ -134,6 +142,7 @@ func (c Client) makePublicAPICall(method string, endpoint string, requestBody io } details.ResponseBody = string(responseBody) + details.SanitizedResponseBody = sanitizeResponseBody(details.ResponseBody) return &details, nil } @@ -177,6 +186,41 @@ func redactRequestLineURLQuery(requestLine string) string { return strings.Join(requestLineParts, " ") + lineSuffix } +// sanitizeResponseBody returns a redacted copy of a raw JSON API response +// body, suitable for debug logging. It leaves the original response body +// returned by makePublicAPICall untouched so that parse*Response call sites +// continue to unmarshal real values. +func sanitizeResponseBody(responseBody string) string { + if strings.TrimSpace(responseBody) == "" { + return responseBody + } + + var response interface{} + if err := json.Unmarshal([]byte(responseBody), &response); err != nil { + return "[REDACTED]" + } + + redactSensitiveJSONFields(response) + + redactedResponseBody, err := json.Marshal(response) + if err != nil { + return "[REDACTED]" + } + + return string(redactedResponseBody) +} + +// redactErrorResponseFields redacts sensitive JSON fields and headers echoed +// back in an API error response, including its free-form Details map, +// before the error response is marshaled into the returned error string. +func redactErrorResponseFields(errRes *errorResponse) { + if errRes == nil || len(errRes.Details) == 0 { + return + } + + redactSensitiveJSONFields(errRes.Details) +} + func redactSensitiveRequestBody(requestDump string) string { headers, body, separator, ok := splitRequestDump(requestDump) if !ok || strings.TrimSpace(body) == "" { diff --git a/syntheticsclientv2/synthetics_test.go b/syntheticsclientv2/synthetics_test.go index 7fe4863..fef7808 100644 --- a/syntheticsclientv2/synthetics_test.go +++ b/syntheticsclientv2/synthetics_test.go @@ -19,6 +19,7 @@ package syntheticsclientv2 import ( "bytes" + "encoding/json" "log" "net/http" "net/http/httptest" @@ -413,3 +414,110 @@ func TestMakePublicAPICallDoesNotExposeRawRequest(t *testing.T) { t.Fatalf("expected sanitized RequestBody to include redacted content field, but saw: %s", details.RequestBody) } } + +func TestMakePublicAPICallRedactsSensitiveFieldsInSanitizedResponseBody(t *testing.T) { + testMux = http.NewServeMux() + testServer = httptest.NewServer(testMux) + defer testServer.Close() + + totpSecret := "totp-secret-material" + certContent := "private-certificate-material" + certPassword := "private-key-password" + + testMux.HandleFunc("/totps/1", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"totp":{"id":1,"name":"login-totp","secret":"` + totpSecret + `","digits":6,"privateKey":{"content":"` + certContent + `","password":"` + certPassword + `"}}}`)) + }) + + testConfigurableClient := NewConfigurableClient("apiKey", "realm", ClientArgs{ + publicBaseUrl: testServer.URL, + }) + + details, err := testConfigurableClient.makePublicAPICall("GET", "/totps/1", bytes.NewBufferString("{}"), nil) + if err != nil { + t.Fatalf("expected no error, but saw: %s", err.Error()) + } + + for _, secret := range []string{totpSecret, certContent, certPassword} { + if strings.Contains(details.SanitizedResponseBody, secret) { + t.Fatalf("sanitized response body leaked %q: %s", secret, details.SanitizedResponseBody) + } + } + for _, redacted := range []string{`"secret":"[REDACTED]"`, `"content":"[REDACTED]"`, `"password":"[REDACTED]"`} { + if !strings.Contains(details.SanitizedResponseBody, redacted) { + t.Fatalf("sanitized response body missing %q: %s", redacted, details.SanitizedResponseBody) + } + } + if !strings.Contains(details.SanitizedResponseBody, `"digits":6`) { + t.Fatalf("sanitized response body should preserve unrelated fields, but saw: %s", details.SanitizedResponseBody) + } + if !strings.Contains(details.SanitizedResponseBody, `"name":"login-totp"`) { + t.Fatalf("sanitized response body should preserve unrelated fields, but saw: %s", details.SanitizedResponseBody) + } + + if strings.Contains(details.ResponseBody, "[REDACTED]") { + t.Fatalf("expected raw ResponseBody used for parsing to remain unredacted, but saw: %s", details.ResponseBody) + } + if !strings.Contains(details.ResponseBody, totpSecret) { + t.Fatalf("expected raw ResponseBody to retain real secret for parse*Response call sites, but saw: %s", details.ResponseBody) + } + + var parsed TotpVariableV2Response + if err := json.Unmarshal([]byte(details.ResponseBody), &parsed); err != nil { + t.Fatalf("expected raw ResponseBody to remain valid JSON for parsing, but saw error: %s", err.Error()) + } + if parsed.Totp.Secret != totpSecret { + t.Fatalf("expected parsed response to retain real secret, but saw: %s", parsed.Totp.Secret) + } +} + +func TestMakePublicAPICallSanitizedResponseBodyHandlesEmptyAndMalformedBodies(t *testing.T) { + if got := sanitizeResponseBody(""); got != "" { + t.Fatalf("expected empty response body to remain unchanged, but saw: %q", got) + } + if got := sanitizeResponseBody(" "); got != " " { + t.Fatalf("expected blank response body to remain unchanged, but saw: %q", got) + } + + malformed := `{"totp":{"secret":"unterminated` + if got := sanitizeResponseBody(malformed); got != "[REDACTED]" { + t.Fatalf("expected malformed response body to be fully redacted, but saw: %q", got) + } +} + +func TestMakePublicAPICallRedactsErrorResponseDetails(t *testing.T) { + testMux = http.NewServeMux() + testServer = httptest.NewServer(testMux) + defer testServer.Close() + + echoedPassword := "echoed-password-secret" + echoedContent := "echoed-content-secret" + + testMux.HandleFunc("/tests", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"status":"400","message":"validation failed","details":{"password":"` + echoedPassword + `","content":"` + echoedContent + `","field":"name"}}`)) + }) + + testConfigurableClient := NewConfigurableClient("apiKey", "realm", ClientArgs{ + publicBaseUrl: testServer.URL, + }) + + _, err := testConfigurableClient.makePublicAPICall("POST", "/tests", bytes.NewBufferString(`{}`), nil) + if err == nil { + t.Fatal("expected an error for a 400 status code") + } + + for _, secret := range []string{echoedPassword, echoedContent} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("error response leaked %q: %s", secret, err.Error()) + } + } + for _, redacted := range []string{`"password":"[REDACTED]"`, `"content":"[REDACTED]"`} { + if !strings.Contains(err.Error(), redacted) { + t.Fatalf("error response missing %q: %s", redacted, err.Error()) + } + } + if !strings.Contains(err.Error(), `"field":"name"`) { + t.Fatalf("error response should preserve unrelated fields, but saw: %s", err.Error()) + } +}