From 1d252735d7b9b8d1c08a45b840d9c93acbfc7bd3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 7 May 2025 18:47:01 -0700 Subject: [PATCH 1/3] feat: Add secret redaction via TEST_SERVER_SECRETS This commit introduces a new feature to redact sensitive information from recorded requests and responses based on a list of secrets provided via the environment variable. Secrets specified in (comma-separated) will be replaced with REDACTED in the request path, headers, and body during both recording and replaying. This redaction happens before the request checksum is computed, ensuring that recordings with secrets can still be replayed correctly. --- CHANGELOG.md | 1 + cmd/record.go | 11 +- cmd/replay.go | 11 +- internal/config/config.go | 1 + internal/record/record.go | 4 +- internal/record/recording_https_proxy.go | 7 +- internal/replay/replay.go | 4 +- internal/replay/replay_http_server.go | 39 +++-- internal/store/store.go | 62 ++++++++ internal/store/store_test.go | 193 +++++++++++++++++++++++ 10 files changed, 309 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29aaa8e..0ed91f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A TypeScript SDK for convenient integration in TypeScript projects. +- Secret redaction via the TEST_SERVER_SECRETS environment variable. ## [0.0.1] - 2025-05-05 diff --git a/cmd/record.go b/cmd/record.go index fa14063..90a7a32 100644 --- a/cmd/record.go +++ b/cmd/record.go @@ -16,6 +16,9 @@ limitations under the License. package cmd import ( + "os" + "strings" + "github.com/google/test-server/internal/config" "github.com/google/test-server/internal/record" "github.com/spf13/cobra" @@ -33,7 +36,13 @@ target server, and all requests and responses will be recorded.`, if err != nil { panic(err) } - err = record.Record(config, recordingDir) + + secrets := os.Getenv("TEST_SERVER_SECRETS") + if secrets != "" { + config.SecretsToRedact = strings.Split(secrets, ",") + } + + err = record.Record(config, recordingDir, config.SecretsToRedact) if err != nil { panic(err) } diff --git a/cmd/replay.go b/cmd/replay.go index 3a898a7..2c36eee 100644 --- a/cmd/replay.go +++ b/cmd/replay.go @@ -17,6 +17,9 @@ limitations under the License. package cmd import ( + "os" + "strings" + "github.com/google/test-server/internal/config" "github.com/google/test-server/internal/replay" "github.com/spf13/cobra" @@ -37,7 +40,13 @@ recording is found.`, if err != nil { panic(err) } - err = replay.Replay(config, replayRecordingDir) + + secrets := os.Getenv("TEST_SERVER_SECRETS") + if secrets != "" { + config.SecretsToRedact = strings.Split(secrets, ",") + } + + err = replay.Replay(config, replayRecordingDir, config.SecretsToRedact) if err != nil { panic(err) } diff --git a/internal/config/config.go b/internal/config/config.go index 6668cdb..1566319 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,7 @@ type HeaderReplacement struct { type TestServerConfig struct { Endpoints []EndpointConfig `yaml:"endpoints"` + SecretsToRedact []string `yaml:"-"` // Not loaded from config, set via env var } func ReadConfig(filename string) (*TestServerConfig, error) { diff --git a/internal/record/record.go b/internal/record/record.go index ca04f21..4d6c6c6 100644 --- a/internal/record/record.go +++ b/internal/record/record.go @@ -24,7 +24,7 @@ import ( "github.com/google/test-server/internal/config" ) -func Record(cfg *config.TestServerConfig, recordingDir string) error { +func Record(cfg *config.TestServerConfig, recordingDir string, secretsToRedact []string) error { // Create recording directory if it doesn't exist if err := os.MkdirAll(recordingDir, 0755); err != nil { return fmt.Errorf("failed to create recording directory: %w", err) @@ -41,7 +41,7 @@ func Record(cfg *config.TestServerConfig, recordingDir string) error { defer wg.Done() fmt.Printf("Starting server for %v\n", endpoint) - proxy := NewRecordingHTTPSProxy(&endpoint, recordingDir) + proxy := NewRecordingHTTPSProxy(&endpoint, recordingDir, secretsToRedact) err := proxy.Start() if err != nil { diff --git a/internal/record/recording_https_proxy.go b/internal/record/recording_https_proxy.go index 2fc06f4..c2611fd 100644 --- a/internal/record/recording_https_proxy.go +++ b/internal/record/recording_https_proxy.go @@ -34,13 +34,15 @@ type RecordingHTTPSProxy struct { prevRequestSHA string config *config.EndpointConfig recordingDir string + secretsToRedact []string } -func NewRecordingHTTPSProxy(cfg *config.EndpointConfig, recordingDir string) *RecordingHTTPSProxy { +func NewRecordingHTTPSProxy(cfg *config.EndpointConfig, recordingDir string, secretsToRedact []string) *RecordingHTTPSProxy { return &RecordingHTTPSProxy{ prevRequestSHA: store.HeadSHA, config: cfg, recordingDir: recordingDir, + secretsToRedact: secretsToRedact, } } @@ -99,6 +101,7 @@ func (r *RecordingHTTPSProxy) recordRequest(req *http.Request) (string, error) { } recordedRequest.RedactHeaders(r.config.RedactRequestHeaders) + recordedRequest.Redact(r.secretsToRedact) reqHash, err := recordedRequest.ComputeSum() if err != nil { @@ -166,6 +169,8 @@ func (r *RecordingHTTPSProxy) recordResponse(resp *http.Response, reqHash string return err } + recordedResponse.Redact(r.secretsToRedact) + recordPath := filepath.Join(r.recordingDir, reqHash+".resp") fmt.Printf("Writing response to: %s\n", recordPath) err = os.WriteFile(recordPath, []byte(recordedResponse.Serialize()), 0644) diff --git a/internal/replay/replay.go b/internal/replay/replay.go index 814d9d2..015f852 100644 --- a/internal/replay/replay.go +++ b/internal/replay/replay.go @@ -24,7 +24,7 @@ import ( ) // Replay serves recorded responses for HTTP requests -func Replay(cfg *config.TestServerConfig, recordingDir string) error { +func Replay(cfg *config.TestServerConfig, recordingDir string, secretsToRedact []string) error { // Validate recording directory exists if _, err := os.Stat(recordingDir); os.IsNotExist(err) { return fmt.Errorf("recording directory does not exist: %s", recordingDir) @@ -37,7 +37,7 @@ func Replay(cfg *config.TestServerConfig, recordingDir string) error { for _, endpoint := range cfg.Endpoints { go func(ep config.EndpointConfig) { - server := NewReplayHTTPServer(&endpoint, recordingDir) + server := NewReplayHTTPServer(&endpoint, recordingDir, secretsToRedact) err := server.Start() if err != nil { errChan <- fmt.Errorf("replay error for %s:%d: %w", diff --git a/internal/replay/replay_http_server.go b/internal/replay/replay_http_server.go index 32fb1db..f1e25ee 100644 --- a/internal/replay/replay_http_server.go +++ b/internal/replay/replay_http_server.go @@ -27,16 +27,18 @@ import ( ) type ReplayHTTPServer struct { - prevRequestSHA string - config *config.EndpointConfig - recordingDir string + prevRequestSHA string + config *config.EndpointConfig + recordingDir string + secretsToRedact []string } -func NewReplayHTTPServer(cfg *config.EndpointConfig, recordingDir string) *ReplayHTTPServer { +func NewReplayHTTPServer(cfg *config.EndpointConfig, recordingDir string, secretsToRedact []string) *ReplayHTTPServer { return &ReplayHTTPServer{ - prevRequestSHA: store.HeadSHA, - config: cfg, - recordingDir: recordingDir, + prevRequestSHA: store.HeadSHA, + config: cfg, + recordingDir: recordingDir, + secretsToRedact: secretsToRedact, } } @@ -53,8 +55,15 @@ func (r *ReplayHTTPServer) Start() error { } func (r *ReplayHTTPServer) handleRequest(w http.ResponseWriter, req *http.Request) { - fmt.Printf("Replaying request: %s %s\n", req.Method, req.URL.String()) - reqHash, err := r.computeRequestHash(req) + redactedReq, err := r.createRedactedRequest(req) + if err != nil { + fmt.Printf("Error processing request") + http.Error(w, fmt.Sprintf("Error processing request: %v", err), http.StatusInternalServerError) + return + } + fmt.Printf("Replaying request: %ss\n", redactedReq.Request) + + reqHash, err := redactedReq.ComputeSum() if err != nil { fmt.Printf("Error computing request sum: %v\n", err) http.Error(w, fmt.Sprintf("Error computing request sum: %v", err), http.StatusInternalServerError) @@ -75,20 +84,16 @@ func (r *ReplayHTTPServer) handleRequest(w http.ResponseWriter, req *http.Reques } } -func (r *ReplayHTTPServer) computeRequestHash(req *http.Request) (string, error) { +func (r *ReplayHTTPServer) createRedactedRequest(req *http.Request) (*store.RecordedRequest, error) { recordedRequest, err := store.NewRecordedRequest(req, r.prevRequestSHA, *r.config) if err != nil { - return "", err + return nil, err } recordedRequest.RedactHeaders(r.config.RedactRequestHeaders) + recordedRequest.Redact(r.secretsToRedact) - reqHash, err := recordedRequest.ComputeSum() - if err != nil { - return "", err - } - - return reqHash, nil + return recordedRequest, nil } func (r *ReplayHTTPServer) loadResponse(sha string) (*store.RecordedResponse, error) { diff --git a/internal/store/store.go b/internal/store/store.go index 9f58197..662598a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -24,6 +24,7 @@ import ( "fmt" "io" "net/http" + "regexp" "sort" "strings" @@ -208,6 +209,39 @@ func (r *RecordedRequest) RedactHeaders(headers []string) { } } +// Redact replaces occurrences of specified secrets in the request. +func (r *RecordedRequest) Redact(secrets []string) { + filteredSecrets := []string{} + for _, secret := range secrets { + if secret != "" { + filteredSecrets = append(filteredSecrets, regexp.QuoteMeta(secret)) + } + } + + if len(filteredSecrets) == 0 { + return + } + + // Create a single regex for all secrets + regexPattern := strings.Join(filteredSecrets, "|") + re := regexp.MustCompile(regexPattern) + + // Redact in the request line (path and query) + r.Request = re.ReplaceAllString(r.Request, "REDACTED") + + // Redact in headers + for name, values := range r.Header { + for i, value := range values { + r.Header[name][i] = re.ReplaceAllString(value, "REDACTED") + } + } + + // Redact in body + r.Body = []byte(re.ReplaceAllString(string(r.Body), "REDACTED")) +} + + + type RecordedResponse struct { StatusCode int Header http.Header @@ -255,6 +289,34 @@ func (r *RecordedResponse) Serialize() string { return buffer.String() } +// Redact replaces occurrences of specified secrets in the response. +func (r *RecordedResponse) Redact(secrets []string) { + filteredSecrets := []string{} + for _, secret := range secrets { + if secret != "" { + filteredSecrets = append(filteredSecrets, regexp.QuoteMeta(secret)) + } + } + + if len(filteredSecrets) == 0 { + return + } + + // Create a single regex for all secrets + regexPattern := strings.Join(filteredSecrets, "|") + re := regexp.MustCompile(regexPattern) + + // Redact in headers + for name, values := range r.Header { + for i, value := range values { + r.Header[name][i] = re.ReplaceAllString(value, "REDACTED") + } + } + + // Redact in body + r.Body = []byte(re.ReplaceAllString(string(r.Body), "REDACTED")) +} + // DeserializeResponse deserializes the response. func DeserializeResponse(data []byte) (*RecordedResponse, error) { lines := bytes.SplitN(data, []byte("\n"), 2) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6566dd8..ab15c89 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -334,3 +334,196 @@ type errorReader struct{} func (e *errorReader) Read(p []byte) (n int, err error) { return 0, fmt.Errorf("simulated error") } + +func TestRecordedRequest_Redact(t *testing.T) { + testCases := []struct { + name string + request RecordedRequest + secrets []string + expectedRequest RecordedRequest + }{ + { + name: "Redact secret in request line", + request: RecordedRequest{ + Request: "GET /path/with/secret/abc HTTP/1.1", + Header: http.Header{}, + Body: []byte{}, + }, + secrets: []string{"abc"}, + expectedRequest: RecordedRequest{ + Request: "GET /path/with/secret/REDACTED HTTP/1.1", + Header: http.Header{}, + Body: []byte{}, + }, + }, + { + name: "Redact secret in header", + request: RecordedRequest{ + Request: "GET / HTTP/1.1", + Header: http.Header{"Authorization": []string{"Bearer secret_token_123"}}, + Body: []byte{}, + }, + secrets: []string{"secret_token_123"}, + expectedRequest: RecordedRequest{ + Request: "GET / HTTP/1.1", + Header: http.Header{"Authorization": []string{"Bearer REDACTED"}}, + Body: []byte{}, + }, + }, + { + name: "Redact secret in body", + request: RecordedRequest{ + Request: "POST /data HTTP/1.1", + Header: http.Header{}, + Body: []byte("{\"token\": \"secret_value_456\"}"), + }, + secrets: []string{"secret_value_456"}, + expectedRequest: RecordedRequest{ + Request: "POST /data HTTP/1.1", + Header: http.Header{}, + Body: []byte("{\"token\": \"REDACTED\"}"), + }, + }, + { + name: "Redact multiple secrets", + request: RecordedRequest{ + Request: "GET /path/abc?token=123 HTTP/1.1", + Header: http.Header{"X-Api-Key": []string{"key_value_xyz"}}, + Body: []byte("user=test&password=password123"), + }, + secrets: []string{"abc", "123", "key_value_xyz", "password123"}, + expectedRequest: RecordedRequest{ + Request: "GET /path/REDACTED?token=REDACTED HTTP/1.1", + Header: http.Header{"X-Api-Key": []string{"REDACTED"}}, + Body: []byte("user=test&password=REDACTED"), + }, + }, + { + name: "No secrets to redact", + request: RecordedRequest{ + Request: "GET /path HTTP/1.1", + Header: http.Header{"X-Api-Key": []string{"some_value"}}, + Body: []byte("user=test"), + }, + secrets: []string{}, + expectedRequest: RecordedRequest{ + Request: "GET /path HTTP/1.1", + Header: http.Header{"X-Api-Key": []string{"some_value"}}, + Body: []byte("user=test"), + }, + }, + { + name: "Empty secret in list", + request: RecordedRequest{ + Request: "GET /path/abc HTTP/1.1", + Header: http.Header{}, + Body: []byte{}, + }, + secrets: []string{"", "abc"}, + expectedRequest: RecordedRequest{ + Request: "GET /path/REDACTED HTTP/1.1", + Header: http.Header{}, + Body: []byte{}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.request.Redact(tc.secrets) + require.Equal(t, tc.expectedRequest.Request, tc.request.Request) + require.Equal(t, tc.expectedRequest.Header, tc.request.Header) + require.Equal(t, tc.expectedRequest.Body, tc.request.Body) + }) + } +} + +func TestRecordedResponse_Redact(t *testing.T) { + testCases := []struct { + name string + response RecordedResponse + secrets []string + expectedResponse RecordedResponse + }{ + { + name: "Redact secret in header", + response: RecordedResponse{ + StatusCode: 200, + Header: http.Header{"Set-Cookie": []string{"sessionid=secret_session_id_789"}}, + Body: []byte{}, + }, + secrets: []string{"secret_session_id_789"}, + expectedResponse: RecordedResponse{ + StatusCode: 200, + Header: http.Header{"Set-Cookie": []string{"sessionid=REDACTED"}}, + Body: []byte{}, + }, + }, + { + name: "Redact secret in body", + response: RecordedResponse{ + StatusCode: 200, + Header: http.Header{}, + Body: []byte("{\"user_token\": \"secret_user_token_abc\"}"), + }, + secrets: []string{"secret_user_token_abc"}, + expectedResponse: RecordedResponse{ + StatusCode: 200, + Header: http.Header{}, + Body: []byte("{\"user_token\": \"REDACTED\"}"), + }, + }, + { + name: "Redact multiple secrets", + response: RecordedResponse{ + StatusCode: 200, + Header: http.Header{"X-Response-Secret": []string{"resp_secret_1"}}, + Body: []byte("token=resp_secret_2&id=123"), + }, + secrets: []string{"resp_secret_1", "resp_secret_2"}, + expectedResponse: RecordedResponse{ + StatusCode: 200, + Header: http.Header{"X-Response-Secret": []string{"REDACTED"}}, + Body: []byte("token=REDACTED&id=123"), + }, + }, + { + name: "No secrets to redact", + response: RecordedResponse{ + StatusCode: 200, + Header: http.Header{"X-Response-Secret": []string{"some_value"}}, + Body: []byte("user=test"), + }, + secrets: []string{}, + expectedResponse: RecordedResponse{ + StatusCode: 200, + Header: http.Header{"X-Response-Secret": []string{"some_value"}}, + Body: []byte("user=test"), + }, + }, + { + name: "Empty secret in list", + response: RecordedResponse{ + StatusCode: 200, + Header: http.Header{"Set-Cookie": []string{"sessionid=secret_session_id_789"}}, + Body: []byte{}, + }, + secrets: []string{"", "secret_session_id_789"}, + expectedResponse: RecordedResponse{ + StatusCode: 200, + Header: http.Header{"Set-Cookie": []string{"sessionid=REDACTED"}}, + Body: []byte{}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.response.Redact(tc.secrets) + require.Equal(t, tc.expectedResponse.StatusCode, tc.response.StatusCode) + require.Equal(t, tc.expectedResponse.Header, tc.response.Header) + require.Equal(t, tc.expectedResponse.Body, tc.response.Body) + }) + } +} + From 227312254ade60c23165aa540b71dd4da9a22bf6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 8 May 2025 15:47:41 -0700 Subject: [PATCH 2/3] Single Redact implementation --- cmd/record.go | 8 +- cmd/replay.go | 8 +- internal/config/config.go | 1 - internal/record/record.go | 5 +- internal/record/recording_https_proxy.go | 15 +- internal/redact/redact.go | 83 +++++++ internal/redact/redact_test.go | 195 ++++++++++++++++ internal/replay/replay.go | 5 +- internal/replay/replay_http_server.go | 25 +- internal/store/store.go | 69 ------ internal/store/store_test.go | 284 ----------------------- 11 files changed, 317 insertions(+), 381 deletions(-) create mode 100644 internal/redact/redact.go create mode 100644 internal/redact/redact_test.go diff --git a/cmd/record.go b/cmd/record.go index 90a7a32..cc86fe7 100644 --- a/cmd/record.go +++ b/cmd/record.go @@ -21,6 +21,7 @@ import ( "github.com/google/test-server/internal/config" "github.com/google/test-server/internal/record" + "github.com/google/test-server/internal/redact" "github.com/spf13/cobra" ) @@ -38,11 +39,12 @@ target server, and all requests and responses will be recorded.`, } secrets := os.Getenv("TEST_SERVER_SECRETS") - if secrets != "" { - config.SecretsToRedact = strings.Split(secrets, ",") + redactor, err := redact.NewRedact(strings.Split(secrets, ",")) + if err != nil { + panic(err) } - err = record.Record(config, recordingDir, config.SecretsToRedact) + err = record.Record(config, recordingDir, redactor) if err != nil { panic(err) } diff --git a/cmd/replay.go b/cmd/replay.go index 2c36eee..a5c1103 100644 --- a/cmd/replay.go +++ b/cmd/replay.go @@ -21,6 +21,7 @@ import ( "strings" "github.com/google/test-server/internal/config" + "github.com/google/test-server/internal/redact" "github.com/google/test-server/internal/replay" "github.com/spf13/cobra" ) @@ -42,11 +43,12 @@ recording is found.`, } secrets := os.Getenv("TEST_SERVER_SECRETS") - if secrets != "" { - config.SecretsToRedact = strings.Split(secrets, ",") + redactor, err := redact.NewRedact(strings.Split(secrets, ",")) + if err != nil { + panic(err) } - err = replay.Replay(config, replayRecordingDir, config.SecretsToRedact) + err = replay.Replay(config, replayRecordingDir, redactor) if err != nil { panic(err) } diff --git a/internal/config/config.go b/internal/config/config.go index 1566319..6668cdb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,7 +41,6 @@ type HeaderReplacement struct { type TestServerConfig struct { Endpoints []EndpointConfig `yaml:"endpoints"` - SecretsToRedact []string `yaml:"-"` // Not loaded from config, set via env var } func ReadConfig(filename string) (*TestServerConfig, error) { diff --git a/internal/record/record.go b/internal/record/record.go index 4d6c6c6..91fa154 100644 --- a/internal/record/record.go +++ b/internal/record/record.go @@ -22,9 +22,10 @@ import ( "sync" "github.com/google/test-server/internal/config" + "github.com/google/test-server/internal/redact" ) -func Record(cfg *config.TestServerConfig, recordingDir string, secretsToRedact []string) error { +func Record(cfg *config.TestServerConfig, recordingDir string, redactor *redact.Redact) error { // Create recording directory if it doesn't exist if err := os.MkdirAll(recordingDir, 0755); err != nil { return fmt.Errorf("failed to create recording directory: %w", err) @@ -41,7 +42,7 @@ func Record(cfg *config.TestServerConfig, recordingDir string, secretsToRedact [ defer wg.Done() fmt.Printf("Starting server for %v\n", endpoint) - proxy := NewRecordingHTTPSProxy(&endpoint, recordingDir, secretsToRedact) + proxy := NewRecordingHTTPSProxy(&endpoint, recordingDir, redactor) err := proxy.Start() if err != nil { diff --git a/internal/record/recording_https_proxy.go b/internal/record/recording_https_proxy.go index c2611fd..1ab99da 100644 --- a/internal/record/recording_https_proxy.go +++ b/internal/record/recording_https_proxy.go @@ -26,6 +26,7 @@ import ( "regexp" "github.com/google/test-server/internal/config" + "github.com/google/test-server/internal/redact" "github.com/google/test-server/internal/store" "github.com/gorilla/websocket" ) @@ -34,15 +35,15 @@ type RecordingHTTPSProxy struct { prevRequestSHA string config *config.EndpointConfig recordingDir string - secretsToRedact []string + redactor *redact.Redact } -func NewRecordingHTTPSProxy(cfg *config.EndpointConfig, recordingDir string, secretsToRedact []string) *RecordingHTTPSProxy { +func NewRecordingHTTPSProxy(cfg *config.EndpointConfig, recordingDir string, redactor *redact.Redact) *RecordingHTTPSProxy { return &RecordingHTTPSProxy{ prevRequestSHA: store.HeadSHA, config: cfg, recordingDir: recordingDir, - secretsToRedact: secretsToRedact, + redactor: redactor, } } @@ -100,8 +101,10 @@ func (r *RecordingHTTPSProxy) recordRequest(req *http.Request) (string, error) { return "", err } - recordedRequest.RedactHeaders(r.config.RedactRequestHeaders) - recordedRequest.Redact(r.secretsToRedact) + r.redactor.Headers(recordedRequest.Header) + recordedRequest.Request = r.redactor.String(recordedRequest.Request) + r.redactor.Headers(recordedRequest.Header) + recordedRequest.Body = r.redactor.Bytes(recordedRequest.Body) reqHash, err := recordedRequest.ComputeSum() if err != nil { @@ -169,7 +172,7 @@ func (r *RecordingHTTPSProxy) recordResponse(resp *http.Response, reqHash string return err } - recordedResponse.Redact(r.secretsToRedact) + recordedResponse.Body = r.redactor.Bytes(recordedResponse.Body) recordPath := filepath.Join(r.recordingDir, reqHash+".resp") fmt.Printf("Writing response to: %s\n", recordPath) diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 0000000..5baf9a9 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,83 @@ +/* +Copyright 2025 Google LLC + +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 + + https://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 ( + "regexp" + "strings" +) + +// REDACTED is the string used to replace redacted secrets. +const REDACTED = "REDACTED" + +// Redact holds the compiled regex for redacting secrets. +type Redact struct { + regex *regexp.Regexp +} + +// NewRedact creates a new Redact instance with the given secrets. +func NewRedact(secrets []string) (*Redact, error) { + filteredSecrets := []string{} + for _, secret := range secrets { + if secret != "" { + filteredSecrets = append(filteredSecrets, regexp.QuoteMeta(secret)) + } + } + + if len(filteredSecrets) == 0 { + return &Redact{regex: nil}, nil // No secrets to redact + } + + regexPattern := strings.Join(filteredSecrets, "|") + re, err := regexp.Compile(regexPattern) + if err != nil { + return nil, err + } + + return &Redact{regex: re}, nil +} + +// Headers redacts the secrets in the values of the http.Header. +func (r *Redact) Headers(headers map[string][]string) { + if r == nil || r.regex == nil { + return // No redactor or no secrets configured + } + for name, values := range headers { + for i, value := range values { + headers[name][i] = r.regex.ReplaceAllString(value, REDACTED) + } + } +} + +// String redacts the secrets in the input string. +func (r *Redact) String(input string) string { + if r == nil || r.regex == nil { + return input // No redactor or no secrets configured + } + return r.regex.ReplaceAllString(input, REDACTED) +} + +// Bytes redacts the secrets in the input byte slice. +func (r *Redact) Bytes(input []byte) []byte { + if r == nil || r.regex == nil { + return input // No redactor or no secrets configured + } + if input == nil { + return nil // Return nil if input is nil + } + return r.regex.ReplaceAll(input, []byte(REDACTED)) +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 0000000..ee6952b --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,195 @@ +/* +Copyright 2025 Google LLC + +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 + + https://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 ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRedact_String(t *testing.T) { + testCases := []struct { + name string + input string + secrets []string + expectedOutput string + }{ + { + name: "Redact single secret", + input: "This is a secret: abc", + secrets: []string{"abc"}, + expectedOutput: "This is a secret: REDACTED", + }, + { + name: "Redact multiple secrets", + input: "Secret1: 123, Secret2: xyz", + secrets: []string{"123", "xyz"}, + expectedOutput: "Secret1: REDACTED, Secret2: REDACTED", + }, + { + name: "No secrets to redact", + input: "No secrets here", + secrets: []string{}, + expectedOutput: "No secrets here", + }, + { + name: "Empty input string", + input: "", + secrets: []string{"abc"}, + expectedOutput: "", + }, + { + name: "Empty secret in list", + input: "This is a secret: abc", + secrets: []string{"", "abc"}, + expectedOutput: "This is a secret: REDACTED", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + redactor, err := NewRedact(tc.secrets) + require.NoError(t, err) + actualOutput := redactor.String(tc.input) + require.Equal(t, tc.expectedOutput, actualOutput) + }) + } +} + +func TestRedact_Bytes(t *testing.T) { + testCases := []struct { + name string + input []byte + secrets []string + expectedOutput []byte + }{ + { + name: "Redact single secret", + input: []byte("This is a secret: abc"), + secrets: []string{"abc"}, + expectedOutput: []byte("This is a secret: REDACTED"), + }, + { + name: "Redact multiple secrets", + input: []byte("Secret1: 123, Secret2: xyz"), + secrets: []string{"123", "xyz"}, + expectedOutput: []byte("Secret1: REDACTED, Secret2: REDACTED"), + }, + { + name: "No secrets to redact", + input: []byte("No secrets here"), + secrets: []string{}, + expectedOutput: []byte("No secrets here")}, + { + name: "Empty input bytes", + input: []byte{}, + secrets: []string{"abc"}, + expectedOutput: nil, + }, + { + name: "Empty secret in list", + input: []byte("This is a secret: abc"), + secrets: []string{"", "abc"}, + expectedOutput: []byte("This is a secret: REDACTED"), + }, + { + name: "Nil input bytes", + input: nil, + secrets: []string{"abc"}, + expectedOutput: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + redactor, err := NewRedact(tc.secrets) + require.NoError(t, err) + actualOutput := redactor.Bytes(tc.input) + require.Equal(t, tc.expectedOutput, actualOutput) + }) + } +} + +func TestRedact_Headers(t *testing.T) { + testCases := []struct { + name string + headers http.Header + secrets []string + expectedHeaders http.Header + }{ + { + name: "Redact secret in single header value", + headers: http.Header{ + "Authorization": []string{"Bearer secret_token_123"}, + "Content-Type": []string{"application/json"}, + }, + secrets: []string{"secret_token_123"}, + expectedHeaders: http.Header{ + "Authorization": []string{"Bearer REDACTED"}, + "Content-Type": []string{"application/json"}, + }, + }, + { + name: "Redact secret in multiple header values", + headers: http.Header{ + "Set-Cookie": []string{"sessionid=secret_session_id_789", "other=value"}, + "X-Api-Key": []string{"key_value_xyz"}, + }, + secrets: []string{"secret_session_id_789", "key_value_xyz"}, + expectedHeaders: http.Header{ + "Set-Cookie": []string{"sessionid=REDACTED", "other=value"}, + "X-Api-Key": []string{"REDACTED"}, + }, + }, + { + name: "No secrets to redact", + headers: http.Header{ + "Authorization": []string{"Bearer token"}, + }, + secrets: []string{}, + expectedHeaders: http.Header{ + "Authorization": []string{"Bearer token"}, + }, + }, + { + name: "Empty secret in list", + headers: http.Header{ + "Authorization": []string{"Bearer secret_token_123"}, + }, + secrets: []string{"", "secret_token_123"}, + expectedHeaders: http.Header{ + "Authorization": []string{"Bearer REDACTED"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + redactor, err := NewRedact(tc.secrets) + require.NoError(t, err) + // Create a copy of the headers to avoid modifying the original test case data + headersCopy := make(http.Header) + for k, v := range tc.headers { + headersCopy[k] = append([]string{}, v...) + } + redactor.Headers(headersCopy) + require.Equal(t, tc.expectedHeaders, headersCopy) + }) + } +} diff --git a/internal/replay/replay.go b/internal/replay/replay.go index 015f852..502642b 100644 --- a/internal/replay/replay.go +++ b/internal/replay/replay.go @@ -21,10 +21,11 @@ import ( "os" "github.com/google/test-server/internal/config" + "github.com/google/test-server/internal/redact" ) // Replay serves recorded responses for HTTP requests -func Replay(cfg *config.TestServerConfig, recordingDir string, secretsToRedact []string) error { +func Replay(cfg *config.TestServerConfig, recordingDir string, redactor *redact.Redact) error { // Validate recording directory exists if _, err := os.Stat(recordingDir); os.IsNotExist(err) { return fmt.Errorf("recording directory does not exist: %s", recordingDir) @@ -37,7 +38,7 @@ func Replay(cfg *config.TestServerConfig, recordingDir string, secretsToRedact [ for _, endpoint := range cfg.Endpoints { go func(ep config.EndpointConfig) { - server := NewReplayHTTPServer(&endpoint, recordingDir, secretsToRedact) + server := NewReplayHTTPServer(&endpoint, recordingDir, redactor) err := server.Start() if err != nil { errChan <- fmt.Errorf("replay error for %s:%d: %w", diff --git a/internal/replay/replay_http_server.go b/internal/replay/replay_http_server.go index f1e25ee..8f2275f 100644 --- a/internal/replay/replay_http_server.go +++ b/internal/replay/replay_http_server.go @@ -23,22 +23,23 @@ import ( "path/filepath" "github.com/google/test-server/internal/config" + "github.com/google/test-server/internal/redact" "github.com/google/test-server/internal/store" ) type ReplayHTTPServer struct { - prevRequestSHA string - config *config.EndpointConfig - recordingDir string - secretsToRedact []string + prevRequestSHA string + config *config.EndpointConfig + recordingDir string + redactor *redact.Redact } -func NewReplayHTTPServer(cfg *config.EndpointConfig, recordingDir string, secretsToRedact []string) *ReplayHTTPServer { +func NewReplayHTTPServer(cfg *config.EndpointConfig, recordingDir string, redactor *redact.Redact) *ReplayHTTPServer { return &ReplayHTTPServer{ - prevRequestSHA: store.HeadSHA, - config: cfg, - recordingDir: recordingDir, - secretsToRedact: secretsToRedact, + prevRequestSHA: store.HeadSHA, + config: cfg, + recordingDir: recordingDir, + redactor: redactor, } } @@ -90,8 +91,10 @@ func (r *ReplayHTTPServer) createRedactedRequest(req *http.Request) (*store.Reco return nil, err } - recordedRequest.RedactHeaders(r.config.RedactRequestHeaders) - recordedRequest.Redact(r.secretsToRedact) + r.redactor.Headers(recordedRequest.Header) + recordedRequest.Request = r.redactor.String(recordedRequest.Request) + r.redactor.Headers(recordedRequest.Header) + recordedRequest.Body = r.redactor.Bytes(recordedRequest.Body) return recordedRequest, nil } diff --git a/internal/store/store.go b/internal/store/store.go index 662598a..8baa96d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -24,7 +24,6 @@ import ( "fmt" "io" "net/http" - "regexp" "sort" "strings" @@ -202,46 +201,6 @@ func Deserialize(data string) (*RecordedRequest, error) { return recordedRequest, nil } -// RedactHeaders removes the specified headers from the RecordedRequest. -func (r *RecordedRequest) RedactHeaders(headers []string) { - for _, header := range headers { - r.Header.Del(header) - } -} - -// Redact replaces occurrences of specified secrets in the request. -func (r *RecordedRequest) Redact(secrets []string) { - filteredSecrets := []string{} - for _, secret := range secrets { - if secret != "" { - filteredSecrets = append(filteredSecrets, regexp.QuoteMeta(secret)) - } - } - - if len(filteredSecrets) == 0 { - return - } - - // Create a single regex for all secrets - regexPattern := strings.Join(filteredSecrets, "|") - re := regexp.MustCompile(regexPattern) - - // Redact in the request line (path and query) - r.Request = re.ReplaceAllString(r.Request, "REDACTED") - - // Redact in headers - for name, values := range r.Header { - for i, value := range values { - r.Header[name][i] = re.ReplaceAllString(value, "REDACTED") - } - } - - // Redact in body - r.Body = []byte(re.ReplaceAllString(string(r.Body), "REDACTED")) -} - - - type RecordedResponse struct { StatusCode int Header http.Header @@ -289,34 +248,6 @@ func (r *RecordedResponse) Serialize() string { return buffer.String() } -// Redact replaces occurrences of specified secrets in the response. -func (r *RecordedResponse) Redact(secrets []string) { - filteredSecrets := []string{} - for _, secret := range secrets { - if secret != "" { - filteredSecrets = append(filteredSecrets, regexp.QuoteMeta(secret)) - } - } - - if len(filteredSecrets) == 0 { - return - } - - // Create a single regex for all secrets - regexPattern := strings.Join(filteredSecrets, "|") - re := regexp.MustCompile(regexPattern) - - // Redact in headers - for name, values := range r.Header { - for i, value := range values { - r.Header[name][i] = re.ReplaceAllString(value, "REDACTED") - } - } - - // Redact in body - r.Body = []byte(re.ReplaceAllString(string(r.Body), "REDACTED")) -} - // DeserializeResponse deserializes the response. func DeserializeResponse(data []byte) (*RecordedResponse, error) { lines := bytes.SplitN(data, []byte("\n"), 2) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ab15c89..d2eab4b 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -184,97 +184,6 @@ func TestNewRecordedRequest(t *testing.T) { } } -func TestRecordedRequest_RedactHeaders(t *testing.T) { - testCases := []struct { - name string - request RecordedRequest - headersToRedact []string - expectedHeaders http.Header - }{ - { - name: "Redact single header", - request: RecordedRequest{ - Request: "GET / HTTP/1.1", - Header: http.Header{ - "Accept": []string{"application/xml"}, - "Content-Type": []string{"application/json"}, - }, - Body: []byte{}, - PreviousRequest: HeadSHA, - ServerAddress: "", - Port: 0, - Protocol: "", - }, - headersToRedact: []string{"Content-Type"}, - expectedHeaders: http.Header{ - "Accept": []string{"application/xml"}, - }, - }, - { - name: "Redact multiple headers", - request: RecordedRequest{ - Request: "GET / HTTP/1.1", - Header: http.Header{ - "Accept": []string{"application/xml"}, - "Content-Type": []string{"application/json"}, - "Authorization": []string{"Bearer token"}, - }, - Body: []byte{}, - PreviousRequest: HeadSHA, - ServerAddress: "", - Port: 0, - Protocol: "", - }, - headersToRedact: []string{"Content-Type", "Authorization"}, - expectedHeaders: http.Header{ - "Accept": []string{"application/xml"}, - }, - }, - { - name: "Redact non-existent header", - request: RecordedRequest{ - Request: "GET / HTTP/1.1", - Header: http.Header{ - "Accept": []string{"application/xml"}, - }, - Body: []byte{}, - PreviousRequest: HeadSHA, - ServerAddress: "", - Port: 0, - Protocol: "", - }, - headersToRedact: []string{"Non-Existent"}, - expectedHeaders: http.Header{ - "Accept": []string{"application/xml"}, - }, - }, - { - name: "Redact all headers", - request: RecordedRequest{ - Request: "GET / HTTP/1.1", - Header: http.Header{ - "Accept": []string{"application/xml"}, - "Content-Type": []string{"application/json"}, - }, - Body: []byte{}, - PreviousRequest: HeadSHA, - ServerAddress: "", - Port: 0, - Protocol: "", - }, - headersToRedact: []string{"Accept", "Content-Type"}, - expectedHeaders: http.Header{}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - tc.request.RedactHeaders(tc.headersToRedact) - require.Equal(t, tc.expectedHeaders, tc.request.Header, "RedactHeaders() result mismatch") - }) - } -} - func TestRecordedRequest_Deserialize(t *testing.T) { testCases := []struct { name string @@ -334,196 +243,3 @@ type errorReader struct{} func (e *errorReader) Read(p []byte) (n int, err error) { return 0, fmt.Errorf("simulated error") } - -func TestRecordedRequest_Redact(t *testing.T) { - testCases := []struct { - name string - request RecordedRequest - secrets []string - expectedRequest RecordedRequest - }{ - { - name: "Redact secret in request line", - request: RecordedRequest{ - Request: "GET /path/with/secret/abc HTTP/1.1", - Header: http.Header{}, - Body: []byte{}, - }, - secrets: []string{"abc"}, - expectedRequest: RecordedRequest{ - Request: "GET /path/with/secret/REDACTED HTTP/1.1", - Header: http.Header{}, - Body: []byte{}, - }, - }, - { - name: "Redact secret in header", - request: RecordedRequest{ - Request: "GET / HTTP/1.1", - Header: http.Header{"Authorization": []string{"Bearer secret_token_123"}}, - Body: []byte{}, - }, - secrets: []string{"secret_token_123"}, - expectedRequest: RecordedRequest{ - Request: "GET / HTTP/1.1", - Header: http.Header{"Authorization": []string{"Bearer REDACTED"}}, - Body: []byte{}, - }, - }, - { - name: "Redact secret in body", - request: RecordedRequest{ - Request: "POST /data HTTP/1.1", - Header: http.Header{}, - Body: []byte("{\"token\": \"secret_value_456\"}"), - }, - secrets: []string{"secret_value_456"}, - expectedRequest: RecordedRequest{ - Request: "POST /data HTTP/1.1", - Header: http.Header{}, - Body: []byte("{\"token\": \"REDACTED\"}"), - }, - }, - { - name: "Redact multiple secrets", - request: RecordedRequest{ - Request: "GET /path/abc?token=123 HTTP/1.1", - Header: http.Header{"X-Api-Key": []string{"key_value_xyz"}}, - Body: []byte("user=test&password=password123"), - }, - secrets: []string{"abc", "123", "key_value_xyz", "password123"}, - expectedRequest: RecordedRequest{ - Request: "GET /path/REDACTED?token=REDACTED HTTP/1.1", - Header: http.Header{"X-Api-Key": []string{"REDACTED"}}, - Body: []byte("user=test&password=REDACTED"), - }, - }, - { - name: "No secrets to redact", - request: RecordedRequest{ - Request: "GET /path HTTP/1.1", - Header: http.Header{"X-Api-Key": []string{"some_value"}}, - Body: []byte("user=test"), - }, - secrets: []string{}, - expectedRequest: RecordedRequest{ - Request: "GET /path HTTP/1.1", - Header: http.Header{"X-Api-Key": []string{"some_value"}}, - Body: []byte("user=test"), - }, - }, - { - name: "Empty secret in list", - request: RecordedRequest{ - Request: "GET /path/abc HTTP/1.1", - Header: http.Header{}, - Body: []byte{}, - }, - secrets: []string{"", "abc"}, - expectedRequest: RecordedRequest{ - Request: "GET /path/REDACTED HTTP/1.1", - Header: http.Header{}, - Body: []byte{}, - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - tc.request.Redact(tc.secrets) - require.Equal(t, tc.expectedRequest.Request, tc.request.Request) - require.Equal(t, tc.expectedRequest.Header, tc.request.Header) - require.Equal(t, tc.expectedRequest.Body, tc.request.Body) - }) - } -} - -func TestRecordedResponse_Redact(t *testing.T) { - testCases := []struct { - name string - response RecordedResponse - secrets []string - expectedResponse RecordedResponse - }{ - { - name: "Redact secret in header", - response: RecordedResponse{ - StatusCode: 200, - Header: http.Header{"Set-Cookie": []string{"sessionid=secret_session_id_789"}}, - Body: []byte{}, - }, - secrets: []string{"secret_session_id_789"}, - expectedResponse: RecordedResponse{ - StatusCode: 200, - Header: http.Header{"Set-Cookie": []string{"sessionid=REDACTED"}}, - Body: []byte{}, - }, - }, - { - name: "Redact secret in body", - response: RecordedResponse{ - StatusCode: 200, - Header: http.Header{}, - Body: []byte("{\"user_token\": \"secret_user_token_abc\"}"), - }, - secrets: []string{"secret_user_token_abc"}, - expectedResponse: RecordedResponse{ - StatusCode: 200, - Header: http.Header{}, - Body: []byte("{\"user_token\": \"REDACTED\"}"), - }, - }, - { - name: "Redact multiple secrets", - response: RecordedResponse{ - StatusCode: 200, - Header: http.Header{"X-Response-Secret": []string{"resp_secret_1"}}, - Body: []byte("token=resp_secret_2&id=123"), - }, - secrets: []string{"resp_secret_1", "resp_secret_2"}, - expectedResponse: RecordedResponse{ - StatusCode: 200, - Header: http.Header{"X-Response-Secret": []string{"REDACTED"}}, - Body: []byte("token=REDACTED&id=123"), - }, - }, - { - name: "No secrets to redact", - response: RecordedResponse{ - StatusCode: 200, - Header: http.Header{"X-Response-Secret": []string{"some_value"}}, - Body: []byte("user=test"), - }, - secrets: []string{}, - expectedResponse: RecordedResponse{ - StatusCode: 200, - Header: http.Header{"X-Response-Secret": []string{"some_value"}}, - Body: []byte("user=test"), - }, - }, - { - name: "Empty secret in list", - response: RecordedResponse{ - StatusCode: 200, - Header: http.Header{"Set-Cookie": []string{"sessionid=secret_session_id_789"}}, - Body: []byte{}, - }, - secrets: []string{"", "secret_session_id_789"}, - expectedResponse: RecordedResponse{ - StatusCode: 200, - Header: http.Header{"Set-Cookie": []string{"sessionid=REDACTED"}}, - Body: []byte{}, - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - tc.response.Redact(tc.secrets) - require.Equal(t, tc.expectedResponse.StatusCode, tc.response.StatusCode) - require.Equal(t, tc.expectedResponse.Header, tc.response.Header) - require.Equal(t, tc.expectedResponse.Body, tc.response.Body) - }) - } -} - From dcd7d1f03b38d548c5c998d633116400909d0c84 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 9 May 2025 07:05:37 -0700 Subject: [PATCH 3/3] Keep redacting headers by key --- internal/record/recording_https_proxy.go | 4 +- internal/replay/replay_http_server.go | 5 +- internal/store/store.go | 7 ++ internal/store/store_test.go | 91 ++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/internal/record/recording_https_proxy.go b/internal/record/recording_https_proxy.go index 1ab99da..68dbbd2 100644 --- a/internal/record/recording_https_proxy.go +++ b/internal/record/recording_https_proxy.go @@ -101,9 +101,11 @@ func (r *RecordingHTTPSProxy) recordRequest(req *http.Request) (string, error) { return "", err } + // Redact headers by key + recordedRequest.RedactHeaders(r.config.RedactRequestHeaders) + // Redacts secrets from header values r.redactor.Headers(recordedRequest.Header) recordedRequest.Request = r.redactor.String(recordedRequest.Request) - r.redactor.Headers(recordedRequest.Header) recordedRequest.Body = r.redactor.Bytes(recordedRequest.Body) reqHash, err := recordedRequest.ComputeSum() diff --git a/internal/replay/replay_http_server.go b/internal/replay/replay_http_server.go index 8f2275f..b9ee2ad 100644 --- a/internal/replay/replay_http_server.go +++ b/internal/replay/replay_http_server.go @@ -91,8 +91,9 @@ func (r *ReplayHTTPServer) createRedactedRequest(req *http.Request) (*store.Reco return nil, err } - r.redactor.Headers(recordedRequest.Header) - recordedRequest.Request = r.redactor.String(recordedRequest.Request) + // Redact headers by key + recordedRequest.RedactHeaders(r.config.RedactRequestHeaders) + // Redacts secrets from header values r.redactor.Headers(recordedRequest.Header) recordedRequest.Body = r.redactor.Bytes(recordedRequest.Body) diff --git a/internal/store/store.go b/internal/store/store.go index 8baa96d..9f58197 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -201,6 +201,13 @@ func Deserialize(data string) (*RecordedRequest, error) { return recordedRequest, nil } +// RedactHeaders removes the specified headers from the RecordedRequest. +func (r *RecordedRequest) RedactHeaders(headers []string) { + for _, header := range headers { + r.Header.Del(header) + } +} + type RecordedResponse struct { StatusCode int Header http.Header diff --git a/internal/store/store_test.go b/internal/store/store_test.go index d2eab4b..6566dd8 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -184,6 +184,97 @@ func TestNewRecordedRequest(t *testing.T) { } } +func TestRecordedRequest_RedactHeaders(t *testing.T) { + testCases := []struct { + name string + request RecordedRequest + headersToRedact []string + expectedHeaders http.Header + }{ + { + name: "Redact single header", + request: RecordedRequest{ + Request: "GET / HTTP/1.1", + Header: http.Header{ + "Accept": []string{"application/xml"}, + "Content-Type": []string{"application/json"}, + }, + Body: []byte{}, + PreviousRequest: HeadSHA, + ServerAddress: "", + Port: 0, + Protocol: "", + }, + headersToRedact: []string{"Content-Type"}, + expectedHeaders: http.Header{ + "Accept": []string{"application/xml"}, + }, + }, + { + name: "Redact multiple headers", + request: RecordedRequest{ + Request: "GET / HTTP/1.1", + Header: http.Header{ + "Accept": []string{"application/xml"}, + "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer token"}, + }, + Body: []byte{}, + PreviousRequest: HeadSHA, + ServerAddress: "", + Port: 0, + Protocol: "", + }, + headersToRedact: []string{"Content-Type", "Authorization"}, + expectedHeaders: http.Header{ + "Accept": []string{"application/xml"}, + }, + }, + { + name: "Redact non-existent header", + request: RecordedRequest{ + Request: "GET / HTTP/1.1", + Header: http.Header{ + "Accept": []string{"application/xml"}, + }, + Body: []byte{}, + PreviousRequest: HeadSHA, + ServerAddress: "", + Port: 0, + Protocol: "", + }, + headersToRedact: []string{"Non-Existent"}, + expectedHeaders: http.Header{ + "Accept": []string{"application/xml"}, + }, + }, + { + name: "Redact all headers", + request: RecordedRequest{ + Request: "GET / HTTP/1.1", + Header: http.Header{ + "Accept": []string{"application/xml"}, + "Content-Type": []string{"application/json"}, + }, + Body: []byte{}, + PreviousRequest: HeadSHA, + ServerAddress: "", + Port: 0, + Protocol: "", + }, + headersToRedact: []string{"Accept", "Content-Type"}, + expectedHeaders: http.Header{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.request.RedactHeaders(tc.headersToRedact) + require.Equal(t, tc.expectedHeaders, tc.request.Header, "RedactHeaders() result mismatch") + }) + } +} + func TestRecordedRequest_Deserialize(t *testing.T) { testCases := []struct { name string