Skip to content

Commit 0cb6452

Browse files
committed
feat: add secret redaction and GitHub PR comment integration
Two features from community review feedback: 1. etch record --redact: Scrubs PII and secrets from snapshots before saving. Catches emails, credit cards, SSNs, phone numbers, bearer tokens, AWS keys, and long hex tokens. Sensitive headers (Authorization, Cookie, Set-Cookie) are fully replaced with [REDACTED]. Prevents accidental commit of live secrets to version control. 2. GitHub PR comments: New workflow (.github/workflows/etch-pr.yml) that runs etch test on PRs and posts a comment showing any API changes detected. Also includes a standalone shell script (scripts/pr-comment.sh) for custom CI setups.
1 parent b3742db commit 0cb6452

7 files changed

Lines changed: 327 additions & 1 deletion

File tree

.github/workflows/etch-pr.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Etch PR Check
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
permissions:
8+
contents: read
9+
pull-requests: write
10+
11+
jobs:
12+
etch-test:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-go@v5
18+
with:
19+
go-version-file: 'go.mod'
20+
21+
- name: Build etch
22+
run: make build
23+
24+
- name: Run etch test
25+
id: etch
26+
continue-on-error: true
27+
run: |
28+
./etch test --ci --snap-dir .etch/snapshots 2>&1 | tee /tmp/etch-output.txt
29+
echo "exit_code=$?" >> $GITHUB_OUTPUT
30+
31+
- name: Get diff output
32+
id: diff
33+
run: |
34+
DIFF=$(./etch diff --ci --snap-dir .etch/snapshots 2>&1 || true)
35+
echo "output<<EOF" >> $GITHUB_OUTPUT
36+
echo "$DIFF" >> $GITHUB_OUTPUT
37+
echo "EOF" >> $GITHUB_OUTPUT
38+
39+
- name: Comment on PR
40+
uses: actions/github-script@v7
41+
with:
42+
script: |
43+
const diff = `${{ steps.diff.outputs.output }}`;
44+
const exitCode = '${{ steps.etch.outputs.exit_code }}';
45+
46+
let body;
47+
if (!diff || diff.includes('No pending diffs')) {
48+
body = '## Etch API Snapshot Test\n\n:white_check_mark: No API changes detected.';
49+
} else {
50+
body = `## Etch API Snapshot Test\n\n:warning: API changes detected:\n\n\`\`\`\n${diff}\n\`\`\`\n\nRun \`etch approve\` to accept these changes.`;
51+
}
52+
53+
await github.rest.issues.createComment({
54+
owner: context.repo.owner,
55+
repo: context.repo.repo,
56+
issue_number: context.issue.number,
57+
body: body
58+
});

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,10 +462,11 @@ go test -race ./... # race detector
462462
- [x] Environment variable support in snapshots (`{{ENV_VAR}}`)
463463
- [x] HTML diff reports (`etch report`)
464464
- [x] Watch mode - continuous monitoring (`etch watch`)
465+
- [x] Secret/PII redaction (`etch record --redact`)
466+
- [x] GitHub PR comment integration
465467

466468
### Planned
467469

468-
- [ ] GitHub PR comments with diff summaries
469470
- [ ] gRPC / GraphQL support
470471

471472
## Contributing

cmd/etch/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/ojuschugh1/etch/internal/noise"
3030
"github.com/ojuschugh1/etch/internal/openapi"
3131
"github.com/ojuschugh1/etch/internal/proxy"
32+
"github.com/ojuschugh1/etch/internal/redact"
3233
"github.com/ojuschugh1/etch/internal/report"
3334
"github.com/ojuschugh1/etch/internal/schema"
3435
"github.com/ojuschugh1/etch/internal/snapshot"
@@ -190,6 +191,7 @@ func isCI(flags config.CLIFlags) bool {
190191

191192
func runRecord(args []string) int {
192193
fs := flag.NewFlagSet("record", flag.ExitOnError)
194+
redactSecrets := fs.Bool("redact", false, "Scrub PII and secrets from snapshots before saving")
193195
fs.Usage = func() {
194196
fmt.Println("Usage: etch record [flags]")
195197
fmt.Println()
@@ -223,6 +225,12 @@ func runRecord(args []string) int {
223225
handler.EnvExpander = envvar.NewExpander(cfg.Env)
224226
}
225227

228+
// wire secret redaction if requested
229+
if *redactSecrets {
230+
handler.Redactor = redact.New()
231+
fmt.Println("Secret redaction enabled - PII and tokens will be scrubbed from snapshots")
232+
}
233+
226234
addr := fmt.Sprintf(":%d", cfg.Port)
227235
srv := proxy.NewProxyServer(addr, proxy.ModeRecord, cam, handler)
228236

internal/proxy/record.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/ojuschugh1/etch/internal/envvar"
99
"github.com/ojuschugh1/etch/internal/hash"
10+
"github.com/ojuschugh1/etch/internal/redact"
1011
"github.com/ojuschugh1/etch/internal/snapshot"
1112
)
1213

@@ -16,6 +17,7 @@ type RecordHandler struct {
1617
HashComputer *hash.HashComputer
1718
SnapshotStore *snapshot.SnapshotStore
1819
EnvExpander *envvar.Expander // optional, collapses URLs to {{VAR}} placeholders
20+
Redactor *redact.Redactor // optional, scrubs PII/secrets before saving
1921
}
2022

2123
// NewRecordHandler creates a RecordHandler with the given hash computer and
@@ -61,6 +63,12 @@ func (h *RecordHandler) HandleRequest(req *http.Request, resp *http.Response) er
6163
Body: string(body),
6264
}
6365

66+
// scrub PII and secrets if redactor is configured
67+
if h.Redactor != nil {
68+
entry.Body = h.Redactor.RedactBody(entry.Body)
69+
entry.Headers = h.Redactor.RedactHeaders(entry.Headers)
70+
}
71+
6472
// Persist the snapshot.
6573
if err := h.SnapshotStore.Record(host, reqHash, entry); err != nil {
6674
return fmt.Errorf("record: persist snapshot: %w", err)

internal/redact/redact.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package redact
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
// patterns that look like secrets or PII
9+
var defaultPatterns = []struct {
10+
name string
11+
pattern *regexp.Regexp
12+
replace string
13+
}{
14+
// auth tokens
15+
{"bearer-token", regexp.MustCompile(`(?i)(bearer\s+)[a-zA-Z0-9._\-]+`), "${1}[REDACTED]"},
16+
{"api-key-value", regexp.MustCompile(`(?i)(api[_-]?key[":\s]+)[a-zA-Z0-9._\-]{16,}`), "${1}[REDACTED]"},
17+
{"secret-value", regexp.MustCompile(`(?i)(secret[":\s]+)[a-zA-Z0-9._\-]{16,}`), "${1}[REDACTED]"},
18+
19+
// emails
20+
{"email", regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`), "[EMAIL]"},
21+
22+
// credit cards (basic patterns)
23+
{"credit-card", regexp.MustCompile(`\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b`), "[CARD]"},
24+
25+
// SSN
26+
{"ssn", regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`), "[SSN]"},
27+
28+
// phone numbers (US format)
29+
{"phone", regexp.MustCompile(`\b\+?1?[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{4}\b`), "[PHONE]"},
30+
31+
// AWS keys
32+
{"aws-access-key", regexp.MustCompile(`AKIA[0-9A-Z]{16}`), "[AWS_KEY]"},
33+
{"aws-secret-key", regexp.MustCompile(`(?i)(aws_secret_access_key[":\s=]+)[a-zA-Z0-9/+=]{40}`), "${1}[REDACTED]"},
34+
35+
// generic long hex strings that look like tokens (32+ chars)
36+
{"hex-token", regexp.MustCompile(`\b[a-f0-9]{40,}\b`), "[TOKEN]"},
37+
}
38+
39+
// Redactor scrubs sensitive data from strings before they get saved to snapshots.
40+
type Redactor struct {
41+
patterns []struct {
42+
name string
43+
pattern *regexp.Regexp
44+
replace string
45+
}
46+
}
47+
48+
// New creates a redactor with the default set of PII/secret patterns.
49+
func New() *Redactor {
50+
return &Redactor{patterns: defaultPatterns}
51+
}
52+
53+
// RedactBody scrubs sensitive patterns from a response body string.
54+
func (r *Redactor) RedactBody(body string) string {
55+
for _, p := range r.patterns {
56+
body = p.pattern.ReplaceAllString(body, p.replace)
57+
}
58+
return body
59+
}
60+
61+
// RedactHeaders scrubs sensitive values from response headers.
62+
// Completely replaces values for known sensitive header names.
63+
func (r *Redactor) RedactHeaders(headers map[string][]string) map[string][]string {
64+
result := make(map[string][]string, len(headers))
65+
sensitiveHeaders := map[string]bool{
66+
"authorization": true,
67+
"x-api-key": true,
68+
"cookie": true,
69+
"set-cookie": true,
70+
"x-csrf-token": true,
71+
"x-auth-token": true,
72+
}
73+
74+
for k, vals := range headers {
75+
if sensitiveHeaders[strings.ToLower(k)] {
76+
result[k] = []string{"[REDACTED]"}
77+
} else {
78+
newVals := make([]string, len(vals))
79+
for i, v := range vals {
80+
newVals[i] = r.RedactBody(v)
81+
}
82+
result[k] = newVals
83+
}
84+
}
85+
return result
86+
}
87+
88+
// HasSensitiveContent returns true if the string contains patterns
89+
// that look like they might be secrets or PII.
90+
func (r *Redactor) HasSensitiveContent(s string) bool {
91+
for _, p := range r.patterns {
92+
if p.pattern.MatchString(s) {
93+
return true
94+
}
95+
}
96+
return false
97+
}

internal/redact/redact_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package redact
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestRedactBody_Email(t *testing.T) {
9+
r := New()
10+
got := r.RedactBody(`{"email":"alice@example.com","name":"Alice"}`)
11+
if strings.Contains(got, "alice@example.com") {
12+
t.Errorf("email not redacted: %s", got)
13+
}
14+
if !strings.Contains(got, "[EMAIL]") {
15+
t.Errorf("expected [EMAIL] placeholder: %s", got)
16+
}
17+
if !strings.Contains(got, "Alice") {
18+
t.Errorf("name should be preserved: %s", got)
19+
}
20+
}
21+
22+
func TestRedactBody_CreditCard(t *testing.T) {
23+
r := New()
24+
got := r.RedactBody(`{"card":"4111-1111-1111-1111","amount":99}`)
25+
if strings.Contains(got, "4111") {
26+
t.Errorf("card not redacted: %s", got)
27+
}
28+
if !strings.Contains(got, "[CARD]") {
29+
t.Errorf("expected [CARD]: %s", got)
30+
}
31+
}
32+
33+
func TestRedactBody_BearerToken(t *testing.T) {
34+
r := New()
35+
got := r.RedactBody(`{"auth":"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123"}`)
36+
if strings.Contains(got, "eyJhbGci") {
37+
t.Errorf("bearer token not redacted: %s", got)
38+
}
39+
}
40+
41+
func TestRedactBody_AWSKey(t *testing.T) {
42+
r := New()
43+
got := r.RedactBody(`{"key":"AKIAIOSFODNN7EXAMPLE"}`)
44+
if strings.Contains(got, "AKIAIOSFODNN7EXAMPLE") {
45+
t.Errorf("AWS key not redacted: %s", got)
46+
}
47+
}
48+
49+
func TestRedactBody_NoSecrets(t *testing.T) {
50+
r := New()
51+
input := `{"name":"Alice","age":30,"active":true}`
52+
got := r.RedactBody(input)
53+
if got != input {
54+
t.Errorf("clean body should be unchanged: %s", got)
55+
}
56+
}
57+
58+
func TestRedactHeaders_Authorization(t *testing.T) {
59+
r := New()
60+
headers := map[string][]string{
61+
"Authorization": {"Bearer sk-abc123xyz"},
62+
"Content-Type": {"application/json"},
63+
}
64+
got := r.RedactHeaders(headers)
65+
if got["Authorization"][0] != "[REDACTED]" {
66+
t.Errorf("auth header not redacted: %s", got["Authorization"][0])
67+
}
68+
if got["Content-Type"][0] != "application/json" {
69+
t.Errorf("content-type should be unchanged: %s", got["Content-Type"][0])
70+
}
71+
}
72+
73+
func TestRedactHeaders_Cookie(t *testing.T) {
74+
r := New()
75+
headers := map[string][]string{
76+
"Cookie": {"session=abc123; token=xyz789"},
77+
"Set-Cookie": {"session=new123; Path=/"},
78+
}
79+
got := r.RedactHeaders(headers)
80+
if got["Cookie"][0] != "[REDACTED]" {
81+
t.Errorf("cookie not redacted: %s", got["Cookie"][0])
82+
}
83+
if got["Set-Cookie"][0] != "[REDACTED]" {
84+
t.Errorf("set-cookie not redacted: %s", got["Set-Cookie"][0])
85+
}
86+
}
87+
88+
func TestHasSensitiveContent(t *testing.T) {
89+
r := New()
90+
if !r.HasSensitiveContent("alice@test.com") {
91+
t.Error("should detect email")
92+
}
93+
if !r.HasSensitiveContent("AKIAIOSFODNN7EXAMPLE") {
94+
t.Error("should detect AWS key")
95+
}
96+
if r.HasSensitiveContent("just a normal string") {
97+
t.Error("should not flag normal text")
98+
}
99+
}
100+
101+
func TestRedactBody_SSN(t *testing.T) {
102+
r := New()
103+
got := r.RedactBody(`{"ssn":"123-45-6789"}`)
104+
if strings.Contains(got, "123-45-6789") {
105+
t.Errorf("SSN not redacted: %s", got)
106+
}
107+
}

scripts/pr-comment.sh

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/bin/bash
2+
# Post etch diff results as a GitHub PR comment.
3+
# Usage: scripts/pr-comment.sh
4+
#
5+
# Requires:
6+
# GITHUB_TOKEN - GitHub API token with PR comment permissions
7+
# GITHUB_REPOSITORY - owner/repo (set automatically in GitHub Actions)
8+
# PR_NUMBER - the pull request number
9+
#
10+
# Typically used in a GitHub Actions workflow:
11+
#
12+
# - name: Run etch test
13+
# run: etch test --ci --snap-dir .etch/snapshots 2>&1 | tee /tmp/etch-output.txt || true
14+
#
15+
# - name: Comment on PR
16+
# if: github.event_name == 'pull_request'
17+
# env:
18+
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19+
# PR_NUMBER: ${{ github.event.pull_request.number }}
20+
# run: bash scripts/pr-comment.sh
21+
22+
set -e
23+
24+
if [ -z "$GITHUB_TOKEN" ] || [ -z "$GITHUB_REPOSITORY" ] || [ -z "$PR_NUMBER" ]; then
25+
echo "Missing required env vars: GITHUB_TOKEN, GITHUB_REPOSITORY, PR_NUMBER"
26+
exit 1
27+
fi
28+
29+
# run etch diff and capture output
30+
DIFF_OUTPUT=$(etch diff --ci --snap-dir .etch/snapshots 2>&1 || true)
31+
32+
if [ -z "$DIFF_OUTPUT" ] || echo "$DIFF_OUTPUT" | grep -q "No pending diffs"; then
33+
BODY="## Etch API Snapshot Test\n\n:white_check_mark: No API changes detected."
34+
else
35+
# escape for JSON
36+
ESCAPED=$(echo "$DIFF_OUTPUT" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
37+
BODY="## Etch API Snapshot Test\n\n:warning: API changes detected:\n\n\`\`\`\n${ESCAPED}\n\`\`\`\n\nRun \`etch approve\` to accept these changes."
38+
fi
39+
40+
# post the comment
41+
curl -s -X POST \
42+
-H "Authorization: token $GITHUB_TOKEN" \
43+
-H "Accept: application/vnd.github.v3+json" \
44+
"https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
45+
-d "{\"body\": \"$BODY\"}"
46+
47+
echo "PR comment posted."

0 commit comments

Comments
 (0)