fix(02): scan leftover passwords and redact secrets in diffs (WR-04)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user