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")
|
store.Set("id:album", "1")
|
||||||
step := Step{
|
step := Step{
|
||||||
ID: "page",
|
ID: "page",
|
||||||
Request: Request{Method: http.MethodGet, Path: "/albums/1"},
|
Request: Request{Method: http.MethodGet, Path: "/albums/1"},
|
||||||
Response: Response{
|
Response: Response{
|
||||||
Status: 200,
|
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) {
|
func TestScrubFormFieldDespiteSubstringSecrets(t *testing.T) {
|
||||||
store, err := OpenStore("")
|
store, err := OpenStore("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -129,10 +129,10 @@ func (e *MismatchError) Error() string {
|
|||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
}
|
}
|
||||||
if d.Byte {
|
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
|
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 {
|
if b.Len() == 0 {
|
||||||
|
|||||||
@@ -553,7 +553,7 @@ func (c *Coverage) markUnrecorded(route Route) {
|
|||||||
func (c *Coverage) addDiffs(id string, res Result) {
|
func (c *Coverage) addDiffs(id string, res Result) {
|
||||||
for _, sr := range res.Steps {
|
for _, sr := range res.Steps {
|
||||||
for _, d := range sr.Diffs {
|
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 (
|
var (
|
||||||
placeholderRe = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
placeholderRe = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
||||||
jwtShapeRe = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`)
|
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,}`)
|
invShapeRe = regexp.MustCompile(`inv_[A-Za-z0-9]{8,}`)
|
||||||
cookieRe = regexp.MustCompile(`(?i)auth_token=([^;]+)`)
|
cookieRe = regexp.MustCompile(`(?i)auth_token=([^;]+)`)
|
||||||
secretFormRe = regexp.MustCompile(`(?i)client_secret=([^&\s]+)`)
|
secretFormRe = regexp.MustCompile(`(?i)client_secret=([^&\s]+)`)
|
||||||
pkceFormRe = regexp.MustCompile(`(?i)code_verifier=([^&\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.
|
// Store holds named capture values. When Path is set it is a mode-0600 private file.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -623,9 +633,55 @@ func remainingCredential(s string) string {
|
|||||||
if pkceFormRe.MatchString(s) {
|
if pkceFormRe.MatchString(s) {
|
||||||
return "pkce"
|
return "pkce"
|
||||||
}
|
}
|
||||||
|
if leftoverPassword(s) {
|
||||||
|
return "password"
|
||||||
|
}
|
||||||
|
if leftoverAccessToken(s) {
|
||||||
|
return "access_token"
|
||||||
|
}
|
||||||
return ""
|
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 {
|
func varsOutsideFixtures(varsPath, fixtures string) error {
|
||||||
if varsPath == "" || fixtures == "" {
|
if varsPath == "" || fixtures == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user