feat(02-02): capture, scrub, and strictly diff stateful flows

Resolve named placeholders from a private variable store, mask
dates and ids after shape checks, and keep comparing independent steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-17 12:32:49 +02:00
parent bb6a5a91c9
commit aa165fe3d0
13 changed files with 1390 additions and 203 deletions

View File

@@ -56,6 +56,7 @@ type Proxy struct {
upstream *url.URL
rp *httputil.ReverseProxy
limit int64
store *Store
mu sync.Mutex
sessions map[string]*sessionBuf
@@ -83,15 +84,18 @@ func NewProxy(cfg ProxyConfig) (*Proxy, error) {
if err := validateRules(cfg.Rules); err != nil {
return nil, err
}
if cfg.VarsPath != "" {
if err := prepareVarsFile(cfg.VarsPath, cfg.Fixtures); err != nil {
return nil, err
}
if err := varsOutsideFixtures(cfg.VarsPath, cfg.Fixtures); err != nil {
return nil, err
}
store, err := OpenStore(cfg.VarsPath)
if err != nil {
return nil, err
}
p := &Proxy{
cfg: cfg,
upstream: upstream,
limit: maxBody(cfg.MaxBody),
store: store,
sessions: make(map[string]*sessionBuf),
failed: make(map[string]error),
}
@@ -268,9 +272,15 @@ func (p *Proxy) recordStep(state *captureState, resp *http.Response, respBody []
if route != nil {
step.Capture = append([]CaptureRule(nil), route.Capture...)
}
if err := rejectUnclassifiedCredentials(step); err != nil {
if err := CaptureStep(p.store, &step); err != nil {
return fmt.Errorf("tide: session %q: %w", state.session, err)
}
if err := ScrubStep(p.store, &step); err != nil {
return fmt.Errorf("tide: session %q: %w", state.session, err)
}
if err := p.store.Save(); err != nil {
return err
}
buf.steps = append(buf.steps, step)
return p.writeSessionLocked(buf)
}
@@ -365,180 +375,6 @@ func isLoopbackHost(host string) bool {
return ip != nil && ip.IsLoopback()
}
func prepareVarsFile(path, fixtures string) error {
absVars, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("tide: vars path: %w", err)
}
absFix, err := filepath.Abs(fixtures)
if err != nil {
return fmt.Errorf("tide: fixtures path: %w", err)
}
if absVars == absFix || strings.HasPrefix(absVars, absFix+string(os.PathSeparator)) {
return fmt.Errorf("tide: vars file %q must be outside fixtures %q", path, fixtures)
}
if st, err := os.Stat(absVars); err == nil {
if st.IsDir() {
return fmt.Errorf("tide: vars %q is a directory", path)
}
if err := os.Chmod(absVars, 0o600); err != nil {
return fmt.Errorf("tide: chmod vars: %w", err)
}
return nil
} else if !os.IsNotExist(err) {
return fmt.Errorf("tide: stat vars: %w", err)
}
if err := os.MkdirAll(filepath.Dir(absVars), 0o755); err != nil {
return fmt.Errorf("tide: create vars dir: %w", err)
}
if err := os.WriteFile(absVars, []byte("{}\n"), 0o600); err != nil {
return fmt.Errorf("tide: create vars: %w", err)
}
return os.Chmod(absVars, 0o600)
}
func isTruncated(err error) bool {
return err != nil && strings.Contains(err.Error(), "exceeds")
}
func rejectUnclassifiedCredentials(step Step) error {
var parts []string
for _, v := range step.Request.Headers {
parts = append(parts, v)
}
parts = append(parts, step.Request.Query, string(step.Request.Body))
for _, v := range step.Response.Headers {
parts = append(parts, v)
}
parts = append(parts, string(step.Response.Body))
classified := classifiedNames(step.Capture)
for _, part := range parts {
if hit := firstCredential(part); hit != "" && !classified[hit] {
return fmt.Errorf("unclassified credential-shaped value in step %s", step.ID)
}
}
return nil
}
func classifiedNames(rules []CaptureRule) map[string]bool {
out := make(map[string]bool)
for _, rule := range rules {
if rule.Category != "" {
out[rule.Category] = true
}
out[rule.As] = true
}
return out
}
func firstCredential(s string) string {
if s == "" {
return ""
}
if jwtRe.MatchString(s) {
return "jwt"
}
if invRe.MatchString(s) {
return "token"
}
lower := strings.ToLower(s)
if strings.Contains(lower, "auth_token=") {
return "cookie"
}
if strings.Contains(lower, "client_secret=") {
return "oauth_secret"
}
if strings.Contains(lower, "code_verifier=") {
return "pkce"
}
return ""
}
var (
jwtRe = mustCompileJWT()
invRe = mustCompileInv()
)
func mustCompileJWT() *regexpJWT {
return &regexpJWT{}
}
func mustCompileInv() *regexpInv {
return &regexpInv{}
}
// tiny wrappers keep the credential regexes local without extra files in task 1.
type regexpJWT struct{}
func (regexpJWT) MatchString(s string) bool {
return jwtLooksLike(s)
}
type regexpInv struct{}
func (regexpInv) MatchString(s string) bool {
return strings.Contains(s, "inv_") && invLooksLike(s)
}
func jwtLooksLike(s string) bool {
const prefix = "eyJ"
for i := 0; i < len(s); i++ {
j := strings.Index(s[i:], prefix)
if j < 0 {
return false
}
i += j
if token := jwtAt(s[i:]); token != "" {
return true
}
i++
}
return false
}
func jwtAt(s string) string {
parts := 0
n := 0
for n < len(s) {
c := s[n]
if isJWTByte(c) {
n++
continue
}
if c == '.' {
parts++
n++
if parts > 2 {
return ""
}
continue
}
break
}
if parts == 2 && n >= 20 {
return s[:n]
}
return ""
}
func isJWTByte(c byte) bool {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
}
func invLooksLike(s string) bool {
for {
i := strings.Index(s, "inv_")
if i < 0 {
return false
}
rest := s[i+4:]
n := 0
for n < len(rest) && isJWTByte(rest[n]) {
n++
}
if n >= 8 {
return true
}
s = rest
}
}