Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion cmd/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ 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/google/test-server/internal/redact"
"github.com/spf13/cobra"
)

Expand All @@ -33,7 +37,14 @@ 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")
redactor, err := redact.NewRedact(strings.Split(secrets, ","))
if err != nil {
panic(err)
}

err = record.Record(config, recordingDir, redactor)
if err != nil {
panic(err)
}
Expand Down
13 changes: 12 additions & 1 deletion cmd/replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ limitations under the License.
package cmd

import (
"os"
"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"
)
Expand All @@ -37,7 +41,14 @@ recording is found.`,
if err != nil {
panic(err)
}
err = replay.Replay(config, replayRecordingDir)

secrets := os.Getenv("TEST_SERVER_SECRETS")
redactor, err := redact.NewRedact(strings.Split(secrets, ","))
if err != nil {
panic(err)
}

err = replay.Replay(config, replayRecordingDir, redactor)
if err != nil {
panic(err)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/record/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) 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)
Expand All @@ -41,7 +42,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, redactor)
err := proxy.Start()

if err != nil {
Expand Down
12 changes: 11 additions & 1 deletion internal/record/recording_https_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -34,13 +35,15 @@ type RecordingHTTPSProxy struct {
prevRequestSHA string
config *config.EndpointConfig
recordingDir string
redactor *redact.Redact
}

func NewRecordingHTTPSProxy(cfg *config.EndpointConfig, recordingDir string) *RecordingHTTPSProxy {
func NewRecordingHTTPSProxy(cfg *config.EndpointConfig, recordingDir string, redactor *redact.Redact) *RecordingHTTPSProxy {
return &RecordingHTTPSProxy{
prevRequestSHA: store.HeadSHA,
config: cfg,
recordingDir: recordingDir,
redactor: redactor,
}
}

Expand Down Expand Up @@ -98,7 +101,12 @@ 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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are line 104 and 106 duplicate?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, the previous line here was actually redacting headers by key, fixed.

recordedRequest.Request = r.redactor.String(recordedRequest.Request)
recordedRequest.Body = r.redactor.Bytes(recordedRequest.Body)

reqHash, err := recordedRequest.ComputeSum()
if err != nil {
Expand Down Expand Up @@ -166,6 +174,8 @@ func (r *RecordingHTTPSProxy) recordResponse(resp *http.Response, reqHash string
return err
}

recordedResponse.Body = r.redactor.Bytes(recordedResponse.Body)

recordPath := filepath.Join(r.recordingDir, reqHash+".resp")
fmt.Printf("Writing response to: %s\n", recordPath)
err = os.WriteFile(recordPath, []byte(recordedResponse.Serialize()), 0644)
Expand Down
83 changes: 83 additions & 0 deletions internal/redact/redact.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading