From 0ac9dc45d068388b6b7898067d1252b7ea47d164 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 17 Sep 2026 14:43:01 +0200 Subject: [PATCH] fix(02): scan leftover passwords and redact secrets in diffs (WR-04) Co-authored-by: Cursor --- tide/capture_test.go | 50 +++++++++++++++++++++++++++++++- tide/flow.go | 4 +-- tide/manifest.go | 2 +- tide/variables.go | 68 ++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 114 insertions(+), 10 deletions(-) diff --git a/tide/capture_test.go b/tide/capture_test.go index a6bd655..35d3c63 100644 --- a/tide/capture_test.go +++ b/tide/capture_test.go @@ -405,7 +405,7 @@ func TestScrubShortIDsLeavePaginationAndIPv4Literal(t *testing.T) { } store.Set("id:album", "1") step := Step{ - ID: "page", + ID: "page", Request: Request{Method: http.MethodGet, Path: "/albums/1"}, Response: Response{ Status: 200, @@ -424,6 +424,54 @@ func TestScrubShortIDsLeavePaginationAndIPv4Literal(t *testing.T) { } } +func TestScrubRejectsUnknownPasswordKeepsAllowlist(t *testing.T) { + store, err := OpenStore("") + if err != nil { + t.Fatal(err) + } + allowed := Step{ + ID: "login", + Request: Request{Method: http.MethodPost, Path: "/login", Body: Body(`{"email":"alice@parity.test","password":"parity-alice-pass"}`)}, + Response: Response{Status: 200, Body: Body(`{"ok":true}`)}, + } + if err := ScrubStep(store, &allowed); err != nil { + t.Fatalf("allow-listed test password: %v", err) + } + leaked := Step{ + ID: "login", + Request: Request{Method: http.MethodPost, Path: "/login", Body: Body(`{"email":"alice@parity.test","password":"hunter2-live"}`)}, + Response: Response{Status: 200, Body: Body(`{"ok":true}`)}, + } + if err := ScrubStep(store, &leaked); err == nil || !strings.Contains(err.Error(), "password") { + t.Fatalf("unknown password: %v", err) + } + opaque := Step{ + ID: "tok", + Response: Response{Status: 200, Body: Body(`{"access_token":"not-a-jwt-but-secret"}`)}, + } + if err := ScrubStep(store, &opaque); err == nil || !strings.Contains(err.Error(), "access_token") { + t.Fatalf("opaque access_token: %v", err) + } +} + +func TestMismatchErrorRedactsJWT(t *testing.T) { + err := &MismatchError{Result: Result{Steps: []StepResult{{ + ID: "t", + Diffs: []Diff{{ + Path: "$.token", + Expected: testJWT, + Actual: testJWT + "x", + }}, + }}}} + msg := err.Error() + if strings.Contains(msg, "eyJ") { + t.Fatalf("mismatch leaked jwt: %s", msg) + } + if !strings.Contains(msg, "") { + t.Fatalf("expected redaction: %s", msg) + } +} + func TestScrubFormFieldDespiteSubstringSecrets(t *testing.T) { store, err := OpenStore("") if err != nil { diff --git a/tide/flow.go b/tide/flow.go index 7143734..c6853fe 100644 --- a/tide/flow.go +++ b/tide/flow.go @@ -129,10 +129,10 @@ func (e *MismatchError) Error() string { b.WriteByte('\n') } if d.Byte { - fmt.Fprintf(&b, "step %s: body mismatch at byte %d: expected %s actual %s", step.ID, d.Offset, d.Expected, d.Actual) + fmt.Fprintf(&b, "step %s: body mismatch at byte %d: expected %s actual %s", step.ID, d.Offset, redactSecrets(d.Expected), redactSecrets(d.Actual)) continue } - fmt.Fprintf(&b, "step %s: %s: expected %s actual %s", step.ID, d.Path, d.Expected, d.Actual) + fmt.Fprintf(&b, "step %s: %s: expected %s actual %s", step.ID, d.Path, redactSecrets(d.Expected), redactSecrets(d.Actual)) } } if b.Len() == 0 { diff --git a/tide/manifest.go b/tide/manifest.go index a4c443e..cfc7da6 100644 --- a/tide/manifest.go +++ b/tide/manifest.go @@ -553,7 +553,7 @@ func (c *Coverage) markUnrecorded(route Route) { func (c *Coverage) addDiffs(id string, res Result) { for _, sr := range res.Steps { for _, d := range sr.Diffs { - c.Diffs = append(c.Diffs, fmt.Sprintf("%s %s: %s expected %s actual %s", id, sr.ID, d.Path, d.Expected, d.Actual)) + c.Diffs = append(c.Diffs, fmt.Sprintf("%s %s: %s expected %s actual %s", id, sr.ID, d.Path, redactSecrets(d.Expected), redactSecrets(d.Actual))) } } } diff --git a/tide/variables.go b/tide/variables.go index d0562d9..f3243e4 100644 --- a/tide/variables.go +++ b/tide/variables.go @@ -17,14 +17,24 @@ import ( ) var ( - placeholderRe = regexp.MustCompile(`\{\{([^{}]+)\}\}`) - jwtShapeRe = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) - invShapeRe = regexp.MustCompile(`inv_[A-Za-z0-9]{8,}`) - cookieRe = regexp.MustCompile(`(?i)auth_token=([^;]+)`) - secretFormRe = regexp.MustCompile(`(?i)client_secret=([^&\s]+)`) - pkceFormRe = regexp.MustCompile(`(?i)code_verifier=([^&\s]+)`) + placeholderRe = regexp.MustCompile(`\{\{([^{}]+)\}\}`) + jwtShapeRe = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) + invShapeRe = regexp.MustCompile(`inv_[A-Za-z0-9]{8,}`) + cookieRe = regexp.MustCompile(`(?i)auth_token=([^;]+)`) + secretFormRe = regexp.MustCompile(`(?i)client_secret=([^&\s]+)`) + pkceFormRe = regexp.MustCompile(`(?i)code_verifier=([^&\s]+)`) + passwordJSONRe = regexp.MustCompile(`(?i)"password"\s*:\s*"([^"]*)"`) + passwordFormRe = regexp.MustCompile(`(?i)(?:^|&)password=([^&\s]*)`) + accessTokenJSONRe = regexp.MustCompile(`(?i)"access_token"\s*:\s*"([^"]*)"`) + secretJSONRe = regexp.MustCompile(`(?i)"client_secret"\s*:\s*"[^"]*"`) ) +// allowedTestPasswords are documented onboarding secrets that remain in fixtures +// as plaintext. Any other leftover password-shaped value is a capture leak. +var allowedTestPasswords = map[string]struct{}{ + "parity-alice-pass": {}, +} + // Store holds named capture values. When Path is set it is a mode-0600 private file. type Store struct { mu sync.Mutex @@ -623,9 +633,55 @@ func remainingCredential(s string) string { if pkceFormRe.MatchString(s) { return "pkce" } + if leftoverPassword(s) { + return "password" + } + if leftoverAccessToken(s) { + return "access_token" + } return "" } +func leftoverPassword(s string) bool { + for _, m := range passwordJSONRe.FindAllStringSubmatch(s, -1) { + if m[1] != "" { + if _, ok := allowedTestPasswords[m[1]]; !ok { + return true + } + } + } + for _, m := range passwordFormRe.FindAllStringSubmatch(s, -1) { + v, err := url.QueryUnescape(m[1]) + if err != nil { + v = m[1] + } + if v != "" { + if _, ok := allowedTestPasswords[v]; !ok { + return true + } + } + } + return false +} + +func leftoverAccessToken(s string) bool { + for _, m := range accessTokenJSONRe.FindAllStringSubmatch(s, -1) { + if strings.TrimSpace(m[1]) != "" { + return true + } + } + return false +} + +func redactSecrets(s string) string { + s = jwtShapeRe.ReplaceAllString(s, "") + s = invShapeRe.ReplaceAllString(s, "") + s = secretFormRe.ReplaceAllString(s, "client_secret=") + s = secretJSONRe.ReplaceAllString(s, `"client_secret":""`) + s = cookieRe.ReplaceAllString(s, "auth_token=") + return s +} + func varsOutsideFixtures(varsPath, fixtures string) error { if varsPath == "" || fixtures == "" { return nil