fix(02): scan leftover passwords and redact secrets in diffs (WR-04)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-17 14:43:01 +02:00
parent d3d202e0e2
commit 0ac9dc45d0
4 changed files with 114 additions and 10 deletions

View File

@@ -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, "<redacted-jwt>") {
t.Fatalf("expected redaction: %s", msg)
}
}
func TestScrubFormFieldDespiteSubstringSecrets(t *testing.T) {
store, err := OpenStore("")
if err != nil {

View File

@@ -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 {

View File

@@ -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)))
}
}
}

View File

@@ -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, "<redacted-jwt>")
s = invShapeRe.ReplaceAllString(s, "<redacted-inv>")
s = secretFormRe.ReplaceAllString(s, "client_secret=<redacted>")
s = secretJSONRe.ReplaceAllString(s, `"client_secret":"<redacted>"`)
s = cookieRe.ReplaceAllString(s, "auth_token=<redacted>")
return s
}
func varsOutsideFixtures(varsPath, fixtures string) error {
if varsPath == "" || fixtures == "" {
return nil