fix(02-03): scrub short captured ids only at token boundaries
Numeric seed ids like 1 were substring-replaced through /api/v1 paths and 15-style JSON integers. Keep ReplaceAll for long secrets and isolate short values so the corpus stays replayable. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -124,7 +124,7 @@ func TestScrubRejectsUnclassifiedAndPersistsVars(t *testing.T) {
|
|||||||
t.Fatalf("mode %o", st.Mode().Perm())
|
t.Fatalf("mode %o", st.Mode().Perm())
|
||||||
}
|
}
|
||||||
unknown := Step{
|
unknown := Step{
|
||||||
ID: "bad",
|
ID: "bad",
|
||||||
Response: Response{Body: Body(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJib2IifQ.otherSignatureValue99"}`)},
|
Response: Response{Body: Body(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJib2IifQ.otherSignatureValue99"}`)},
|
||||||
}
|
}
|
||||||
if err := ScrubStep(store, &unknown); err == nil {
|
if err := ScrubStep(store, &unknown); err == nil {
|
||||||
@@ -271,3 +271,43 @@ func post(t *testing.T, url, session, ct, body string) {
|
|||||||
t.Fatalf("%s: %d", url, resp.StatusCode)
|
t.Fatalf("%s: %d", url, resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScrubShortNumericIDsDoNotCorruptPaths(t *testing.T) {
|
||||||
|
store, err := OpenStore("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store.Set("id:alice", "1")
|
||||||
|
store.Set("id:genre", "4")
|
||||||
|
step := Step{
|
||||||
|
ID: "genres",
|
||||||
|
Request: Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_fonoteka/api/v1/genres",
|
||||||
|
Headers: map[string]string{"Authorization": "Bearer x"},
|
||||||
|
},
|
||||||
|
Response: Response{
|
||||||
|
Status: 200,
|
||||||
|
Body: Body(`{"data":[{"id":1,"name":"Rock","album_count":0},{"id":15,"name":"Latin"},{"id":4,"name":"Jazz","album_count":1}]}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := ScrubStep(store, &step); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if step.Request.Path != "/_fonoteka/api/v1/genres" {
|
||||||
|
t.Fatalf("path corrupted: %s", step.Request.Path)
|
||||||
|
}
|
||||||
|
body := string(step.Response.Body)
|
||||||
|
if !strings.Contains(body, `"id":{{id:alice}}`) {
|
||||||
|
t.Fatalf("id 1 not isolated: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"id":15`) {
|
||||||
|
t.Fatalf("id 15 must stay intact: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"id":{{id:genre}}`) {
|
||||||
|
t.Fatalf("id 4 not isolated: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "{{id:alice}}5") || strings.Contains(body, "v{{id:alice}}") {
|
||||||
|
t.Fatalf("substring replace leaked: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -456,11 +456,47 @@ func replaceAll(s string, pairs [][2]string) string {
|
|||||||
if p[0] == "" {
|
if p[0] == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s = strings.ReplaceAll(s, p[0], p[1])
|
if len(p[0]) >= 8 {
|
||||||
|
s = strings.ReplaceAll(s, p[0], p[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s = replaceIsolated(s, p[0], p[1])
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func replaceIsolated(s, old, neu string) string {
|
||||||
|
if old == "" || s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
j := strings.Index(s[i:], old)
|
||||||
|
if j < 0 {
|
||||||
|
b.WriteString(s[i:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j += i
|
||||||
|
leftOK := j == 0 || !isIdentByte(s[j-1])
|
||||||
|
right := j + len(old)
|
||||||
|
rightOK := right == len(s) || !isIdentByte(s[right])
|
||||||
|
if leftOK && rightOK {
|
||||||
|
b.WriteString(s[i:j])
|
||||||
|
b.WriteString(neu)
|
||||||
|
i = right
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(s[i : j+len(old)])
|
||||||
|
i = j + len(old)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIdentByte(c byte) bool {
|
||||||
|
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
func rejectUnclassifiedCredentials(step Step) error {
|
func rejectUnclassifiedCredentials(step Step) error {
|
||||||
check := func(label, s string) error {
|
check := func(label, s string) error {
|
||||||
if hit := remainingCredential(s); hit != "" {
|
if hit := remainingCredential(s); hit != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user