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:
194
tide/proxy.go
194
tide/proxy.go
@@ -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 ®expJWT{}
|
||||
}
|
||||
|
||||
func mustCompileInv() *regexpInv {
|
||||
return ®expInv{}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user