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:
@@ -33,6 +33,8 @@ func parityRecordCommand() bonfire.Command {
|
|||||||
{Name: "spec", Description: "YAML request spec path"},
|
{Name: "spec", Description: "YAML request spec path"},
|
||||||
{Name: "target", Description: "Base URL of the HTTP backend"},
|
{Name: "target", Description: "Base URL of the HTTP backend"},
|
||||||
{Name: "output", Description: "Destination fixture path"},
|
{Name: "output", Description: "Destination fixture path"},
|
||||||
|
{Name: "rules", Description: "Committed YAML capture rules"},
|
||||||
|
{Name: "vars", Description: "Private mode-0600 variable store outside fixtures"},
|
||||||
},
|
},
|
||||||
Run: runParityRecord,
|
Run: runParityRecord,
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ func parityReplayCommand() bonfire.Command {
|
|||||||
Flags: []bonfire.Flag{
|
Flags: []bonfire.Flag{
|
||||||
{Name: "fixtures", Description: "Recorded YAML fixture path"},
|
{Name: "fixtures", Description: "Recorded YAML fixture path"},
|
||||||
{Name: "target", Description: "Base URL of the HTTP backend"},
|
{Name: "target", Description: "Base URL of the HTTP backend"},
|
||||||
|
{Name: "vars", Description: "Private mode-0600 variable store"},
|
||||||
},
|
},
|
||||||
Run: runParityReplay,
|
Run: runParityReplay,
|
||||||
}
|
}
|
||||||
@@ -99,7 +102,22 @@ func runParityRecord(ctx context.Context, in bonfire.Input, out bonfire.Output)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
flow, err := tide.RecordFlow(ctx, spec, tide.RecordConfig{Target: target})
|
cfg := tide.RecordConfig{Target: target}
|
||||||
|
if rulesPath, ok := in.Flag("rules"); ok && strings.TrimSpace(rulesPath) != "" {
|
||||||
|
rules, err := tide.LoadRules(rulesPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Rules = rules
|
||||||
|
}
|
||||||
|
if varsPath, ok := in.Flag("vars"); ok && strings.TrimSpace(varsPath) != "" {
|
||||||
|
store, err := tide.OpenStore(varsPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Store = store
|
||||||
|
}
|
||||||
|
flow, err := tide.RecordFlow(ctx, spec, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -123,7 +141,15 @@ func runParityReplay(ctx context.Context, in bonfire.Input, out bonfire.Output)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result, err := tide.ReplayFlow(ctx, flow, tide.ReplayConfig{Target: target})
|
cfg := tide.ReplayConfig{Target: target}
|
||||||
|
if varsPath, ok := in.Flag("vars"); ok && strings.TrimSpace(varsPath) != "" {
|
||||||
|
store, err := tide.OpenStore(varsPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Store = store
|
||||||
|
}
|
||||||
|
result, err := tide.ReplayFlow(ctx, flow, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
273
tide/capture_test.go
Normal file
273
tide/capture_test.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSJ9.signaturehere123456"
|
||||||
|
|
||||||
|
func TestCaptureAndPlaceholderResolution(t *testing.T) {
|
||||||
|
store, err := OpenStore("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
step := Step{
|
||||||
|
ID: "login",
|
||||||
|
Response: Response{
|
||||||
|
Headers: map[string]string{"Content-Type": "application/json"},
|
||||||
|
Body: Body(`{"token":"` + testJWT + `"}`),
|
||||||
|
},
|
||||||
|
Capture: []CaptureRule{{
|
||||||
|
From: "response.json",
|
||||||
|
Path: "$.token",
|
||||||
|
As: "jwt:alice",
|
||||||
|
Identity: "alice",
|
||||||
|
Category: "jwt",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := CaptureStep(store, &step); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ScrubStep(store, &step); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(step.Response.Body), "{{jwt:alice}}") {
|
||||||
|
t.Fatalf("body not scrubbed: %s", step.Response.Body)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(step.Response.Body), testJWT) {
|
||||||
|
t.Fatal("jwt left in body")
|
||||||
|
}
|
||||||
|
got, err := store.Expand("Bearer {{jwt:alice}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "Bearer "+testJWT {
|
||||||
|
t.Fatalf("expand: %q", got)
|
||||||
|
}
|
||||||
|
if _, err := store.Expand("{{missing}}"); err == nil || !strings.Contains(err.Error(), "unresolved") {
|
||||||
|
t.Fatalf("missing placeholder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
step2 := Step{
|
||||||
|
ID: "pkce",
|
||||||
|
Request: Request{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/authorize",
|
||||||
|
Body: Body("code_verifier=pkceVerifierValue1&client_id=x"),
|
||||||
|
},
|
||||||
|
Response: Response{
|
||||||
|
Status: 302,
|
||||||
|
Headers: map[string]string{"Location": "/cb?code=oauthCode99"},
|
||||||
|
},
|
||||||
|
Capture: []CaptureRule{
|
||||||
|
{From: "request.form", Name: "code_verifier", As: "pkce:alice", Category: "pkce"},
|
||||||
|
{From: "response.location.query", Name: "code", As: "oauth:alice-code", Category: "oauth_code"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := CaptureStep(store, &step2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ScrubStep(store, &step2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(step2.Request.Body), "{{pkce:alice}}") || strings.Contains(string(step2.Request.Body), "pkceVerifierValue1") {
|
||||||
|
t.Fatalf("pkce not scrubbed: %s", step2.Request.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(step2.Response.Headers["Location"], "{{oauth:alice-code}}") {
|
||||||
|
t.Fatalf("code not scrubbed: %v", step2.Response.Headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrubRejectsUnclassifiedAndPersistsVars(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
varsPath := filepath.Join(dir, "vars.yaml")
|
||||||
|
store, err := OpenStore(varsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
step := Step{
|
||||||
|
ID: "login",
|
||||||
|
Response: Response{
|
||||||
|
Headers: map[string]string{"Content-Type": "application/json"},
|
||||||
|
Body: Body(`{"token":"` + testJWT + `"}`),
|
||||||
|
},
|
||||||
|
Capture: []CaptureRule{{From: "response.json", Path: "$.token", As: "jwt:alice", Category: "jwt"}},
|
||||||
|
}
|
||||||
|
if err := CaptureStep(store, &step); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ScrubStep(store, &step); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(varsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), testJWT) {
|
||||||
|
t.Fatalf("vars missing secret:\n%s", raw)
|
||||||
|
}
|
||||||
|
st, err := os.Stat(varsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("mode %o", st.Mode().Perm())
|
||||||
|
}
|
||||||
|
unknown := Step{
|
||||||
|
ID: "bad",
|
||||||
|
Response: Response{Body: Body(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJib2IifQ.otherSignatureValue99"}`)},
|
||||||
|
}
|
||||||
|
if err := ScrubStep(store, &unknown); err == nil {
|
||||||
|
t.Fatal("unclassified leftover jwt must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyLoginTokenAndPKCESession(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/login":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"token":"` + testJWT + `"}`))
|
||||||
|
case r.URL.Path == "/me":
|
||||||
|
if r.Header.Get("Authorization") != "Bearer "+testJWT {
|
||||||
|
http.Error(w, "no", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
case r.URL.Path == "/authorize":
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if !strings.Contains(string(body), "code_verifier=pkceVerifierValue1") {
|
||||||
|
http.Error(w, "missing verifier", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Location", "/cb?code=oauthCode99")
|
||||||
|
w.WriteHeader(http.StatusFound)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
fixtures := t.TempDir()
|
||||||
|
varsPath := filepath.Join(t.TempDir(), "vars.yaml")
|
||||||
|
rules := mustParseRules(t, ""+
|
||||||
|
"client: nuxt\n"+
|
||||||
|
"keep_request_headers:\n"+
|
||||||
|
" - Authorization\n"+
|
||||||
|
" - Content-Type\n"+
|
||||||
|
"keep_response_headers:\n"+
|
||||||
|
" - Content-Type\n"+
|
||||||
|
" - Location\n"+
|
||||||
|
"routes:\n"+
|
||||||
|
" - method: POST\n"+
|
||||||
|
" path: /login\n"+
|
||||||
|
" capture:\n"+
|
||||||
|
" - from: response.json\n"+
|
||||||
|
" path: $.token\n"+
|
||||||
|
" as: jwt:alice\n"+
|
||||||
|
" identity: alice\n"+
|
||||||
|
" category: jwt\n"+
|
||||||
|
" - method: GET\n"+
|
||||||
|
" path: /me\n"+
|
||||||
|
" - method: POST\n"+
|
||||||
|
" path: /authorize\n"+
|
||||||
|
" capture:\n"+
|
||||||
|
" - from: request.form\n"+
|
||||||
|
" name: code_verifier\n"+
|
||||||
|
" as: pkce:alice\n"+
|
||||||
|
" category: pkce\n"+
|
||||||
|
" - from: response.location.query\n"+
|
||||||
|
" name: code\n"+
|
||||||
|
" as: oauth:alice-code\n"+
|
||||||
|
" category: oauth_code\n")
|
||||||
|
proxy, err := NewProxy(ProxyConfig{
|
||||||
|
Listen: "127.0.0.1:0",
|
||||||
|
Upstream: upstream.URL,
|
||||||
|
Fixtures: fixtures,
|
||||||
|
VarsPath: varsPath,
|
||||||
|
Rules: rules,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(proxy.Handler())
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
post(t, srv.URL+"/login", "sess", "application/json", `{}`)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, srv.URL+"/me", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set(SessionHeader, "sess")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+testJWT)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("me: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
post(t, srv.URL+"/authorize", "sess", "application/x-www-form-urlencoded", "code_verifier=pkceVerifierValue1")
|
||||||
|
if err := proxy.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(filepath.Join(fixtures, "nuxt", "sess.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(raw)
|
||||||
|
if strings.Contains(text, testJWT) || strings.Contains(text, "pkceVerifierValue1") || strings.Contains(text, "oauthCode99") {
|
||||||
|
t.Fatalf("secrets in fixture:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "{{jwt:alice}}") || !strings.Contains(text, "{{pkce:alice}}") || !strings.Contains(text, "{{oauth:alice-code}}") {
|
||||||
|
t.Fatalf("placeholders missing:\n%s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
flow, err := LoadFlow(filepath.Join(fixtures, "nuxt", "sess.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store, err := OpenStore(varsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ReplayFlow(context.Background(), flow, ReplayConfig{Target: upstream.URL, Store: store}); err != nil {
|
||||||
|
t.Fatalf("replay captured session: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func post(t *testing.T, url, session, ct, body string) {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set(SessionHeader, session)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
t.Fatalf("%s: %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tide/diff.go
68
tide/diff.go
@@ -5,21 +5,85 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime"
|
"mime"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func compareBodies(want, got Response) []Diff {
|
func compareBodies(want, got Response, step Step) []Diff {
|
||||||
wantJSON := isJSONContentType(want.Headers)
|
wantJSON := isJSONContentType(want.Headers)
|
||||||
gotJSON := isJSONContentType(got.Headers)
|
gotJSON := isJSONContentType(got.Headers)
|
||||||
if wantJSON && gotJSON {
|
if wantJSON && gotJSON {
|
||||||
return diffJSON([]byte(want.Body), []byte(got.Body))
|
wantRaw, wantDiffs := normalizeJSON([]byte(want.Body), step)
|
||||||
|
gotRaw, gotDiffs := normalizeJSON([]byte(got.Body), step)
|
||||||
|
diffs := append([]Diff{}, wantDiffs...)
|
||||||
|
diffs = append(diffs, gotDiffs...)
|
||||||
|
diffs = append(diffs, diffJSON(wantRaw, gotRaw)...)
|
||||||
|
return diffs
|
||||||
}
|
}
|
||||||
return diffBytes([]byte(want.Body), []byte(got.Body))
|
return diffBytes([]byte(want.Body), []byte(got.Body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var globalCompareHeaders = []string{
|
||||||
|
"Content-Type",
|
||||||
|
"X-Total-Count",
|
||||||
|
"Access-Control-Allow-Origin",
|
||||||
|
"Access-Control-Allow-Credentials",
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"Access-Control-Expose-Headers",
|
||||||
|
}
|
||||||
|
|
||||||
|
var extraCompareHeaders = []string{
|
||||||
|
"Cache-Control",
|
||||||
|
"Pragma",
|
||||||
|
"WWW-Authenticate",
|
||||||
|
"Content-Disposition",
|
||||||
|
"Link",
|
||||||
|
}
|
||||||
|
|
||||||
|
var neverCompareHeaders = []string{
|
||||||
|
"Date",
|
||||||
|
"Server",
|
||||||
|
"X-Request-Id",
|
||||||
|
"X-Request-ID",
|
||||||
|
"X-Correlation-Id",
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareHeaders(want, got, extra map[string]string) []Diff {
|
||||||
|
allow := map[string]bool{}
|
||||||
|
for _, n := range globalCompareHeaders {
|
||||||
|
allow[http.CanonicalHeaderKey(n)] = true
|
||||||
|
}
|
||||||
|
for _, n := range extraCompareHeaders {
|
||||||
|
allow[http.CanonicalHeaderKey(n)] = true
|
||||||
|
}
|
||||||
|
for n := range extra {
|
||||||
|
allow[http.CanonicalHeaderKey(n)] = true
|
||||||
|
}
|
||||||
|
for _, n := range neverCompareHeaders {
|
||||||
|
delete(allow, http.CanonicalHeaderKey(n))
|
||||||
|
}
|
||||||
|
var diffs []Diff
|
||||||
|
for k, wv := range want {
|
||||||
|
ck := http.CanonicalHeaderKey(k)
|
||||||
|
if !allow[ck] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gv := headerValue(got, k)
|
||||||
|
if gv == wv {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if gv == "" {
|
||||||
|
gv = "<missing>"
|
||||||
|
}
|
||||||
|
diffs = append(diffs, Diff{Path: "header." + ck, Expected: wv, Actual: gv})
|
||||||
|
}
|
||||||
|
return diffs
|
||||||
|
}
|
||||||
|
|
||||||
func isJSONContentType(headers map[string]string) bool {
|
func isJSONContentType(headers map[string]string) bool {
|
||||||
ct := headerValue(headers, "Content-Type")
|
ct := headerValue(headers, "Content-Type")
|
||||||
if ct == "" {
|
if ct == "" {
|
||||||
|
|||||||
34
tide/diff_test.go
Normal file
34
tide/diff_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiffParityClasses(t *testing.T) {
|
||||||
|
step := Step{ID: "d"}
|
||||||
|
ct := jsonCT()
|
||||||
|
check := func(name, want, got, path string) {
|
||||||
|
t.Helper()
|
||||||
|
diffs := compareBodies(Response{Headers: ct, Body: Body(want)}, Response{Headers: ct, Body: Body(got)}, step)
|
||||||
|
if len(diffs) == 0 {
|
||||||
|
t.Fatalf("%s: expected mismatch", name)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, d := range diffs {
|
||||||
|
if strings.Contains(d.Path, path) && d.Expected != d.Actual {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("%s: want path %s in %+v", name, path, diffs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("null vs array", `{"tracklist":[]}`, `{"tracklist":null}`, "tracklist")
|
||||||
|
check("money string vs number", `{"price":"1.5000"}`, `{"price":1.5}`, "price")
|
||||||
|
check("null vs absent", `{"deleted_at":null}`, `{}`, "deleted_at")
|
||||||
|
check("tri-state bool", `{"is_owner":null}`, `{"is_owner":false}`, "is_owner")
|
||||||
|
check("conditional key", `{"data":{"name":"a"}}`, `{"data":{"name":"a","extra":1}}`, "extra")
|
||||||
|
check("date vs Z", `{"created_at":"2026-01-01T00:00:00+00:00"}`, `{"created_at":"2026-01-01T00:00:00Z"}`, "created_at")
|
||||||
|
}
|
||||||
10
tide/flow.go
10
tide/flow.go
@@ -74,6 +74,8 @@ type RecordConfig struct {
|
|||||||
Target string
|
Target string
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
MaxBody int64
|
MaxBody int64
|
||||||
|
Store *Store
|
||||||
|
Rules Rules
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplayConfig injects the HTTP target, client and body bound for replay.
|
// ReplayConfig injects the HTTP target, client and body bound for replay.
|
||||||
@@ -81,6 +83,7 @@ type ReplayConfig struct {
|
|||||||
Target string
|
Target string
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
MaxBody int64
|
MaxBody int64
|
||||||
|
Store *Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is the outcome of replaying a flow.
|
// Result is the outcome of replaying a flow.
|
||||||
@@ -91,9 +94,10 @@ type Result struct {
|
|||||||
|
|
||||||
// StepResult is the outcome of one replayed step.
|
// StepResult is the outcome of one replayed step.
|
||||||
type StepResult struct {
|
type StepResult struct {
|
||||||
ID string
|
ID string
|
||||||
OK bool
|
OK bool
|
||||||
Diffs []Diff
|
Skipped bool
|
||||||
|
Diffs []Diff
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diff is one structural JSON or raw-byte mismatch.
|
// Diff is one structural JSON or raw-byte mismatch.
|
||||||
|
|||||||
80
tide/flow_test.go
Normal file
80
tide/flow_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFlowMismatchContinuesAndCaptureSkipsRest(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/one":
|
||||||
|
_, _ = w.Write([]byte(`{"data":"no"}`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{"data":"ok"}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
mismatch := Flow{
|
||||||
|
Version: 1,
|
||||||
|
Name: "mismatch-continue",
|
||||||
|
Steps: []Step{
|
||||||
|
{
|
||||||
|
ID: "a",
|
||||||
|
Request: Request{Method: http.MethodGet, Path: "/one"},
|
||||||
|
Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"data":"yes"}`)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "b",
|
||||||
|
Request: Request{Method: http.MethodGet, Path: "/two"},
|
||||||
|
Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"data":"ok"}`)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := ReplayFlow(context.Background(), mismatch, ReplayConfig{Target: srv.URL})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("mismatch must error")
|
||||||
|
}
|
||||||
|
if hits.Load() != 2 {
|
||||||
|
t.Fatalf("mismatch should continue, hits=%d", hits.Load())
|
||||||
|
}
|
||||||
|
if len(res.Steps) != 2 || res.Steps[1].Skipped || !res.Steps[1].OK {
|
||||||
|
t.Fatalf("step b should run and pass: %+v", res.Steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
hits.Store(0)
|
||||||
|
captureFail := Flow{
|
||||||
|
Version: 1,
|
||||||
|
Name: "capture-skip",
|
||||||
|
Steps: []Step{
|
||||||
|
{
|
||||||
|
ID: "a",
|
||||||
|
Request: Request{Method: http.MethodGet, Path: "/two"},
|
||||||
|
Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"data":"ok"}`)},
|
||||||
|
Capture: []CaptureRule{{From: "response.json", Path: "$.token", As: "jwt:alice"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "b",
|
||||||
|
Request: Request{Method: http.MethodGet, Path: "/two"},
|
||||||
|
Response: Response{Status: 200, Headers: jsonCT(), Body: Body(`{"data":"ok"}`)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err = ReplayFlow(context.Background(), captureFail, ReplayConfig{Target: srv.URL})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("failed capture must error")
|
||||||
|
}
|
||||||
|
if hits.Load() != 1 {
|
||||||
|
t.Fatalf("capture fail should skip rest, hits=%d", hits.Load())
|
||||||
|
}
|
||||||
|
if len(res.Steps) != 2 || !res.Steps[1].Skipped {
|
||||||
|
t.Fatalf("step b should skip: %+v", res.Steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
tide/headers_test.go
Normal file
77
tide/headers_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHeadersAllowListIgnoresDate(t *testing.T) {
|
||||||
|
want := Response{
|
||||||
|
Status: 401,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"WWW-Authenticate": `Bearer error="invalid_token"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Date": "Wed, 01 Jan 2020 00:00:00 GMT",
|
||||||
|
},
|
||||||
|
Body: Body(`{"ok":false}`),
|
||||||
|
}
|
||||||
|
got := Response{
|
||||||
|
Status: 401,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"WWW-Authenticate": `Bearer error="invalid_token"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Date": "Thu, 02 Jan 2020 00:00:00 GMT",
|
||||||
|
"Server": "php",
|
||||||
|
},
|
||||||
|
Body: Body(`{"ok":false}`),
|
||||||
|
}
|
||||||
|
diffs := compareStep(Step{ID: "h", Response: want, Headers: map[string]string{"WWW-Authenticate": "", "Cache-Control": ""}}, got)
|
||||||
|
if len(diffs) != 0 {
|
||||||
|
t.Fatalf("Date/Server must be ignored: %+v", diffs)
|
||||||
|
}
|
||||||
|
|
||||||
|
got.Headers["WWW-Authenticate"] = `Bearer error="other"`
|
||||||
|
diffs = compareStep(Step{ID: "h", Response: want}, got)
|
||||||
|
if len(diffs) == 0 {
|
||||||
|
t.Fatal("WWW-Authenticate mismatch must fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(diffs[0].Path), "www-authenticate") {
|
||||||
|
t.Fatalf("path %s", diffs[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadersReplayAgainstServer(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
flow := Flow{
|
||||||
|
Version: 1,
|
||||||
|
Name: "headers",
|
||||||
|
Steps: []Step{{
|
||||||
|
ID: "h",
|
||||||
|
Request: Request{Method: http.MethodGet, Path: "/"},
|
||||||
|
Response: Response{
|
||||||
|
Status: 200,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
},
|
||||||
|
Body: Body(`{"ok":true}`),
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if _, err := ReplayFlow(context.Background(), flow, ReplayConfig{Target: srv.URL}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
tide/normalize.go
Normal file
133
tide/normalize.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var carbonOffsetRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$`)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maskDatetime = "<datetime>"
|
||||||
|
maskID = "<id>"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeJSON(raw []byte, step Step) ([]byte, []Diff) {
|
||||||
|
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
val, err := decodeJSON(raw)
|
||||||
|
if err != nil {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
var diffs []Diff
|
||||||
|
masked := maskValue("$", val, step, &diffs)
|
||||||
|
out, err := json.Marshal(masked)
|
||||||
|
if err != nil {
|
||||||
|
return raw, diffs
|
||||||
|
}
|
||||||
|
return out, diffs
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskValue(path string, val any, step Step, diffs *[]Diff) any {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
out := make(map[string]any, len(v))
|
||||||
|
for k, child := range v {
|
||||||
|
out[k] = maskValue(pathJoin(path, k), child, step, diffs)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(v))
|
||||||
|
for i, child := range v {
|
||||||
|
out[i] = maskValue(fmt.Sprintf("%s[%d]", path, i), child, step, diffs)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return maskLeaf(path, val, step, diffs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskLeaf(path string, val any, step Step, diffs *[]Diff) any {
|
||||||
|
key := lastPathKey(path)
|
||||||
|
if key == "slug" || disabledPath(step, path, key) {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
if isDateKey(key) {
|
||||||
|
return maskDate(path, val, diffs)
|
||||||
|
}
|
||||||
|
if isIDKey(key) {
|
||||||
|
return maskIDValue(path, val, diffs)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskDate(path string, val any, diffs *[]Diff) any {
|
||||||
|
if val == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
|
*diffs = append(*diffs, Diff{Path: path, Expected: "Carbon +00:00 string or null", Actual: formatValue(val)})
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
if !carbonOffsetRe.MatchString(s) {
|
||||||
|
*diffs = append(*diffs, Diff{Path: path, Expected: "Carbon +00:00", Actual: strconvQuote(s)})
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return maskDatetime
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskIDValue(path string, val any, diffs *[]Diff) any {
|
||||||
|
n, ok := val.(json.Number)
|
||||||
|
if !ok {
|
||||||
|
*diffs = append(*diffs, Diff{Path: path, Expected: "integer id", Actual: formatValue(val)})
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
if strings.Contains(string(n), ".") {
|
||||||
|
*diffs = append(*diffs, Diff{Path: path, Expected: "integer id", Actual: "number " + string(n)})
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return maskID
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDateKey(key string) bool {
|
||||||
|
return strings.HasSuffix(key, "_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIDKey(key string) bool {
|
||||||
|
if key == "id" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(key, "_id") && !strings.HasSuffix(key, "_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastPathKey(path string) string {
|
||||||
|
path = strings.TrimPrefix(path, "$.")
|
||||||
|
if i := strings.LastIndex(path, "."); i >= 0 {
|
||||||
|
path = path[i+1:]
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(path, '['); i >= 0 {
|
||||||
|
path = path[:i]
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func disabledPath(step Step, jsonPath, key string) bool {
|
||||||
|
for _, rule := range step.Normalize {
|
||||||
|
if !rule.Disable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := strings.TrimSpace(rule.Path)
|
||||||
|
if p == jsonPath || p == key || strings.TrimPrefix(p, "$.") == strings.TrimPrefix(jsonPath, "$.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func strconvQuote(s string) string {
|
||||||
|
return `"` + s + `"`
|
||||||
|
}
|
||||||
44
tide/normalize_test.go
Normal file
44
tide/normalize_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeDateIDAndDisable(t *testing.T) {
|
||||||
|
step := Step{ID: "n"}
|
||||||
|
want := []byte(`{"id":1,"created_at":"2026-01-01T00:00:00+00:00","slug":"keep-me"}`)
|
||||||
|
gotOK := []byte(`{"id":9,"created_at":"2026-02-02T00:00:00+00:00","slug":"keep-me"}`)
|
||||||
|
gotZ := []byte(`{"id":9,"created_at":"2026-02-02T00:00:00Z","slug":"keep-me"}`)
|
||||||
|
gotStrID := []byte(`{"id":"9","created_at":"2026-02-02T00:00:00+00:00","slug":"keep-me"}`)
|
||||||
|
gotSlug := []byte(`{"id":9,"created_at":"2026-02-02T00:00:00+00:00","slug":"other"}`)
|
||||||
|
|
||||||
|
if diffs := compareBodies(Response{Headers: jsonCT(), Body: Body(want)}, Response{Headers: jsonCT(), Body: Body(gotOK)}, step); len(diffs) != 0 {
|
||||||
|
t.Fatalf("masked date/id should pass: %+v", diffs)
|
||||||
|
}
|
||||||
|
zdiffs := compareBodies(Response{Headers: jsonCT(), Body: Body(want)}, Response{Headers: jsonCT(), Body: Body(gotZ)}, step)
|
||||||
|
if len(zdiffs) == 0 {
|
||||||
|
t.Fatal("Z date must fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(zdiffs[0].Path, "created_at") {
|
||||||
|
t.Fatalf("Z path %s", zdiffs[0].Path)
|
||||||
|
}
|
||||||
|
idDiffs := compareBodies(Response{Headers: jsonCT(), Body: Body(want)}, Response{Headers: jsonCT(), Body: Body(gotStrID)}, step)
|
||||||
|
if len(idDiffs) == 0 || !strings.Contains(idDiffs[0].Path, "id") {
|
||||||
|
t.Fatalf("string id: %+v", idDiffs)
|
||||||
|
}
|
||||||
|
slugDiffs := compareBodies(Response{Headers: jsonCT(), Body: Body(want)}, Response{Headers: jsonCT(), Body: Body(gotSlug)}, step)
|
||||||
|
if len(slugDiffs) == 0 || !strings.Contains(slugDiffs[0].Path, "slug") {
|
||||||
|
t.Fatalf("slug must stay exact: %+v", slugDiffs)
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled := Step{ID: "n", Normalize: []NormalizeRule{{Path: "created_at", Disable: true}}}
|
||||||
|
dDiffs := compareBodies(Response{Headers: jsonCT(), Body: Body(want)}, Response{Headers: jsonCT(), Body: Body(gotOK)}, disabled)
|
||||||
|
if len(dDiffs) == 0 {
|
||||||
|
t.Fatal("disabled date mask must compare raw timestamps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonCT() map[string]string {
|
||||||
|
return map[string]string{"Content-Type": "application/json"}
|
||||||
|
}
|
||||||
194
tide/proxy.go
194
tide/proxy.go
@@ -56,6 +56,7 @@ type Proxy struct {
|
|||||||
upstream *url.URL
|
upstream *url.URL
|
||||||
rp *httputil.ReverseProxy
|
rp *httputil.ReverseProxy
|
||||||
limit int64
|
limit int64
|
||||||
|
store *Store
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sessions map[string]*sessionBuf
|
sessions map[string]*sessionBuf
|
||||||
@@ -83,15 +84,18 @@ func NewProxy(cfg ProxyConfig) (*Proxy, error) {
|
|||||||
if err := validateRules(cfg.Rules); err != nil {
|
if err := validateRules(cfg.Rules); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if cfg.VarsPath != "" {
|
if err := varsOutsideFixtures(cfg.VarsPath, cfg.Fixtures); err != nil {
|
||||||
if err := prepareVarsFile(cfg.VarsPath, cfg.Fixtures); err != nil {
|
return nil, err
|
||||||
return nil, err
|
}
|
||||||
}
|
store, err := OpenStore(cfg.VarsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
p := &Proxy{
|
p := &Proxy{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
upstream: upstream,
|
upstream: upstream,
|
||||||
limit: maxBody(cfg.MaxBody),
|
limit: maxBody(cfg.MaxBody),
|
||||||
|
store: store,
|
||||||
sessions: make(map[string]*sessionBuf),
|
sessions: make(map[string]*sessionBuf),
|
||||||
failed: make(map[string]error),
|
failed: make(map[string]error),
|
||||||
}
|
}
|
||||||
@@ -268,9 +272,15 @@ func (p *Proxy) recordStep(state *captureState, resp *http.Response, respBody []
|
|||||||
if route != nil {
|
if route != nil {
|
||||||
step.Capture = append([]CaptureRule(nil), route.Capture...)
|
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)
|
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)
|
buf.steps = append(buf.steps, step)
|
||||||
return p.writeSessionLocked(buf)
|
return p.writeSessionLocked(buf)
|
||||||
}
|
}
|
||||||
@@ -365,180 +375,6 @@ func isLoopbackHost(host string) bool {
|
|||||||
return ip != nil && ip.IsLoopback()
|
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 {
|
func isTruncated(err error) bool {
|
||||||
return err != nil && strings.Contains(err.Error(), "exceeds")
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,12 +30,27 @@ func RecordFlow(ctx context.Context, spec Flow, cfg RecordConfig) (Flow, error)
|
|||||||
out.Version = CurrentVersion
|
out.Version = CurrentVersion
|
||||||
out.Steps = make([]Step, len(spec.Steps))
|
out.Steps = make([]Step, len(spec.Steps))
|
||||||
copy(out.Steps, spec.Steps)
|
copy(out.Steps, spec.Steps)
|
||||||
for i, step := range spec.Steps {
|
for i := range out.Steps {
|
||||||
resp, err := doStep(ctx, client, cfg.Target, step.Request, limit)
|
step := &out.Steps[i]
|
||||||
|
mergeRouteCaptures(step, cfg.Rules)
|
||||||
|
req, err := expandRequest(step.Request, cfg.Store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Flow{}, fmt.Errorf("tide: record step %s: %w", step.ID, err)
|
return Flow{}, fmt.Errorf("tide: record step %s: %w", step.ID, err)
|
||||||
}
|
}
|
||||||
out.Steps[i].Response = resp
|
resp, err := doStep(ctx, client, cfg.Target, req, limit)
|
||||||
|
if err != nil {
|
||||||
|
return Flow{}, fmt.Errorf("tide: record step %s: %w", step.ID, err)
|
||||||
|
}
|
||||||
|
step.Response = resp
|
||||||
|
if err := CaptureStep(cfg.Store, step); err != nil {
|
||||||
|
return Flow{}, fmt.Errorf("tide: record step %s: %w", step.ID, err)
|
||||||
|
}
|
||||||
|
if err := ScrubStep(cfg.Store, step); err != nil {
|
||||||
|
return Flow{}, fmt.Errorf("tide: record step %s: %w", step.ID, err)
|
||||||
|
}
|
||||||
|
if err := cfg.Store.Save(); err != nil {
|
||||||
|
return Flow{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := validateFlow(out); err != nil {
|
if err := validateFlow(out); err != nil {
|
||||||
return Flow{}, err
|
return Flow{}, err
|
||||||
@@ -100,11 +115,24 @@ func joinURL(target, path, rawQuery string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func keepResponseHeaders(h http.Header) map[string]string {
|
func keepResponseHeaders(h http.Header) map[string]string {
|
||||||
ct := h.Get("Content-Type")
|
return filterHeaders(h, recordedHeaderNames)
|
||||||
if ct == "" {
|
}
|
||||||
return nil
|
|
||||||
}
|
var recordedHeaderNames = []string{
|
||||||
return map[string]string{"Content-Type": ct}
|
"Content-Type",
|
||||||
|
"Location",
|
||||||
|
"Set-Cookie",
|
||||||
|
"Cache-Control",
|
||||||
|
"Pragma",
|
||||||
|
"WWW-Authenticate",
|
||||||
|
"Content-Disposition",
|
||||||
|
"Access-Control-Allow-Origin",
|
||||||
|
"Access-Control-Allow-Credentials",
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"Access-Control-Expose-Headers",
|
||||||
|
"X-Total-Count",
|
||||||
|
"Link",
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBounded(r io.Reader, max int64) ([]byte, error) {
|
func readBounded(r io.Reader, max int64) ([]byte, error) {
|
||||||
|
|||||||
@@ -19,22 +19,44 @@ func ReplayFlow(ctx context.Context, flow Flow, cfg ReplayConfig) (Result, error
|
|||||||
client = defaultClient()
|
client = defaultClient()
|
||||||
}
|
}
|
||||||
limit := maxBody(cfg.MaxBody)
|
limit := maxBody(cfg.MaxBody)
|
||||||
|
store := cfg.Store
|
||||||
|
if store == nil {
|
||||||
|
store = mustMemoryStore()
|
||||||
|
}
|
||||||
result := Result{OK: true, Steps: make([]StepResult, 0, len(flow.Steps))}
|
result := Result{OK: true, Steps: make([]StepResult, 0, len(flow.Steps))}
|
||||||
|
skipRest := false
|
||||||
for _, step := range flow.Steps {
|
for _, step := range flow.Steps {
|
||||||
got, err := doStep(ctx, client, cfg.Target, step.Request, limit)
|
if skipRest {
|
||||||
|
result.Steps = append(result.Steps, StepResult{ID: step.ID, Skipped: true})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sr := StepResult{ID: step.ID, OK: true}
|
||||||
|
req, err := expandRequest(step.Request, store)
|
||||||
|
if err != nil {
|
||||||
|
sr.OK = false
|
||||||
|
result.OK = false
|
||||||
|
sr.Diffs = append(sr.Diffs, Diff{Path: "request", Expected: "resolved placeholders", Actual: err.Error()})
|
||||||
|
result.Steps = append(result.Steps, sr)
|
||||||
|
skipRest = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, err := doStep(ctx, client, cfg.Target, req, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("tide: replay step %s: %w", step.ID, err)
|
return result, fmt.Errorf("tide: replay step %s: %w", step.ID, err)
|
||||||
}
|
}
|
||||||
sr := StepResult{ID: step.ID, OK: true}
|
live := step
|
||||||
if step.Response.Status != 0 && got.Status != step.Response.Status {
|
live.Request = req
|
||||||
|
live.Response = got
|
||||||
|
if err := CaptureStep(store, &live); err != nil {
|
||||||
sr.OK = false
|
sr.OK = false
|
||||||
sr.Diffs = append(sr.Diffs, Diff{
|
result.OK = false
|
||||||
Path: "status",
|
sr.Diffs = append(sr.Diffs, Diff{Path: "capture", Expected: "captured value", Actual: err.Error()})
|
||||||
Expected: fmt.Sprintf("%d", step.Response.Status),
|
result.Steps = append(result.Steps, sr)
|
||||||
Actual: fmt.Sprintf("%d", got.Status),
|
skipRest = true
|
||||||
})
|
continue
|
||||||
}
|
}
|
||||||
sr.Diffs = append(sr.Diffs, compareBodies(step.Response, got)...)
|
_ = ScrubStep(store, &live)
|
||||||
|
sr.Diffs = append(sr.Diffs, compareStep(step, live.Response)...)
|
||||||
if len(sr.Diffs) > 0 {
|
if len(sr.Diffs) > 0 {
|
||||||
sr.OK = false
|
sr.OK = false
|
||||||
result.OK = false
|
result.OK = false
|
||||||
@@ -46,3 +68,22 @@ func ReplayFlow(ctx context.Context, flow Flow, cfg ReplayConfig) (Result, error
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mustMemoryStore() *Store {
|
||||||
|
s, _ := OpenStore("")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareStep(want Step, got Response) []Diff {
|
||||||
|
var diffs []Diff
|
||||||
|
if want.Response.Status != 0 && got.Status != want.Response.Status {
|
||||||
|
diffs = append(diffs, Diff{
|
||||||
|
Path: "status",
|
||||||
|
Expected: fmt.Sprintf("%d", want.Response.Status),
|
||||||
|
Actual: fmt.Sprintf("%d", got.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
diffs = append(diffs, compareHeaders(want.Response.Headers, got.Headers, want.Headers)...)
|
||||||
|
diffs = append(diffs, compareBodies(want.Response, got, want)...)
|
||||||
|
return diffs
|
||||||
|
}
|
||||||
|
|||||||
547
tide/variables.go
Normal file
547
tide/variables.go
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
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]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store holds named capture values. When Path is set it is a mode-0600 private file.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
vals map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenStore loads or creates a private variable map. Empty path is memory-only.
|
||||||
|
func OpenStore(path string) (*Store, error) {
|
||||||
|
s := &Store{path: path, vals: make(map[string]string)}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tide: vars path: %w", err)
|
||||||
|
}
|
||||||
|
s.path = abs
|
||||||
|
st, err := os.Stat(abs)
|
||||||
|
if err == nil {
|
||||||
|
if st.IsDir() {
|
||||||
|
return nil, fmt.Errorf("tide: vars %q is a directory", path)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tide: read vars: %w", err)
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(string(raw))) > 0 {
|
||||||
|
if err := yaml.Unmarshal(raw, &s.vals); err != nil {
|
||||||
|
return nil, fmt.Errorf("tide: parse vars: %w", err)
|
||||||
|
}
|
||||||
|
if s.vals == nil {
|
||||||
|
s.vals = make(map[string]string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Chmod(abs, 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("tide: chmod vars: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("tide: stat vars: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("tide: create vars dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(abs, []byte("{}\n"), 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("tide: create vars: %w", err)
|
||||||
|
}
|
||||||
|
_ = os.Chmod(abs, 0o600)
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the map as YAML with mode 0600. Memory-only stores are a no-op.
|
||||||
|
func (s *Store) Save() error {
|
||||||
|
if s == nil || s.path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
keys := make([]string, 0, len(s.vals))
|
||||||
|
for k := range s.vals {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var b strings.Builder
|
||||||
|
if len(keys) == 0 {
|
||||||
|
b.WriteString("{}\n")
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(&b, "%s: %s\n", strconv.Quote(k), strconv.Quote(s.vals[k]))
|
||||||
|
}
|
||||||
|
raw := []byte(b.String())
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".vars-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tide: vars temp: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
if _, err := tmp.Write(raw); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = tmp.Chmod(0o600)
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, s.path); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Chmod(s.path, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a stored value.
|
||||||
|
func (s *Store) Get(name string) (string, bool) {
|
||||||
|
if s == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
v, ok := s.vals[name]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a named value.
|
||||||
|
func (s *Store) Set(name, value string) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.vals[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand replaces {{name}} placeholders. Unresolved names fail before HTTP send.
|
||||||
|
func (s *Store) Expand(text string) (string, error) {
|
||||||
|
if !strings.Contains(text, "{{") {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
if s == nil {
|
||||||
|
m := placeholderRe.FindStringSubmatch(text)
|
||||||
|
if len(m) > 1 {
|
||||||
|
return "", fmt.Errorf("tide: unresolved placeholder %q", m[1])
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("tide: unresolved placeholder")
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
s.mu.Lock()
|
||||||
|
out := placeholderRe.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
name := m[2 : len(m)-2]
|
||||||
|
v, ok := s.vals[name]
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, name)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
s.mu.Unlock()
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return "", fmt.Errorf("tide: unresolved placeholder %q", missing[0])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandRequest(req Request, store *Store) (Request, error) {
|
||||||
|
out := req
|
||||||
|
var err error
|
||||||
|
out.Path, err = store.Expand(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
return Request{}, err
|
||||||
|
}
|
||||||
|
out.Query, err = store.Expand(req.Query)
|
||||||
|
if err != nil {
|
||||||
|
return Request{}, err
|
||||||
|
}
|
||||||
|
if req.Headers != nil {
|
||||||
|
out.Headers = make(map[string]string, len(req.Headers))
|
||||||
|
for k, v := range req.Headers {
|
||||||
|
out.Headers[k], err = store.Expand(v)
|
||||||
|
if err != nil {
|
||||||
|
return Request{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := store.Expand(string(req.Body))
|
||||||
|
if err != nil {
|
||||||
|
return Request{}, err
|
||||||
|
}
|
||||||
|
out.Body = Body(body)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureStep writes named values from the step into the store.
|
||||||
|
func CaptureStep(store *Store, step *Step) error {
|
||||||
|
if store == nil || step == nil || len(step.Capture) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, rule := range step.Capture {
|
||||||
|
val, err := extractCapture(rule, step.Request, step.Response)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tide: capture %q: %w", rule.As, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(val) == "" {
|
||||||
|
return fmt.Errorf("tide: capture %q was empty", rule.As)
|
||||||
|
}
|
||||||
|
store.Set(rule.As, val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCapture(rule CaptureRule, req Request, resp Response) (string, error) {
|
||||||
|
from := strings.TrimSpace(rule.From)
|
||||||
|
if from == "" {
|
||||||
|
from = "response.json"
|
||||||
|
}
|
||||||
|
switch from {
|
||||||
|
case "response.json":
|
||||||
|
v, err := jsonPathValue([]byte(resp.Body), rule.Path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return scalarString(v), nil
|
||||||
|
case "response.header":
|
||||||
|
v := headerValue(resp.Headers, rule.Name)
|
||||||
|
if v == "" {
|
||||||
|
return "", fmt.Errorf("missing response header %s", rule.Name)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
case "response.query":
|
||||||
|
loc := headerValue(resp.Headers, "Location")
|
||||||
|
if loc == "" {
|
||||||
|
return "", fmt.Errorf("missing Location header")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(loc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
v := u.Query().Get(rule.Name)
|
||||||
|
if v == "" {
|
||||||
|
return "", fmt.Errorf("missing response query %s", rule.Name)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
case "response.location.query":
|
||||||
|
loc := headerValue(resp.Headers, "Location")
|
||||||
|
if loc == "" {
|
||||||
|
return "", fmt.Errorf("missing Location header")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(loc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
v := u.Query().Get(rule.Name)
|
||||||
|
if v == "" {
|
||||||
|
return "", fmt.Errorf("missing Location query %s", rule.Name)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
case "request.form":
|
||||||
|
v, err := formValue(string(req.Body), rule.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
return "", fmt.Errorf("missing form field %s", rule.Name)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
case "request.header":
|
||||||
|
v := headerValue(req.Headers, rule.Name)
|
||||||
|
if v == "" {
|
||||||
|
return "", fmt.Errorf("missing request header %s", rule.Name)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
case "request.query":
|
||||||
|
q, err := url.ParseQuery(req.Query)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
v := q.Get(rule.Name)
|
||||||
|
if v == "" {
|
||||||
|
return "", fmt.Errorf("missing request query %s", rule.Name)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown capture from %q", from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formValue(body, name string) (string, error) {
|
||||||
|
vals, err := url.ParseQuery(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return vals.Get(name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonPathValue(raw []byte, path string) (any, error) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return nil, fmt.Errorf("json path is required")
|
||||||
|
}
|
||||||
|
root, err := decodeJSON(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v, err := walkJSONPath(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkJSONPath(root any, path string) (any, error) {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "$" || path == "" {
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "$") {
|
||||||
|
path = "$." + path
|
||||||
|
}
|
||||||
|
cur := root
|
||||||
|
rest := strings.TrimPrefix(path, "$")
|
||||||
|
for rest != "" {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(rest, "."):
|
||||||
|
rest = rest[1:]
|
||||||
|
name, next := splitPathSeg(rest)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("invalid json path %s", path)
|
||||||
|
}
|
||||||
|
obj, ok := cur.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s is not an object", path)
|
||||||
|
}
|
||||||
|
v, ok := obj[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing %s", "$."+name)
|
||||||
|
}
|
||||||
|
cur = v
|
||||||
|
rest = next
|
||||||
|
case strings.HasPrefix(rest, "["):
|
||||||
|
end := strings.IndexByte(rest, ']')
|
||||||
|
if end < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid json path %s", path)
|
||||||
|
}
|
||||||
|
idx, err := atoi(rest[1:end])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr, ok := cur.([]any)
|
||||||
|
if !ok || idx < 0 || idx >= len(arr) {
|
||||||
|
return nil, fmt.Errorf("missing %s[%d]", path, idx)
|
||||||
|
}
|
||||||
|
cur = arr[idx]
|
||||||
|
rest = rest[end+1:]
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid json path %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPathSeg(s string) (name, rest string) {
|
||||||
|
i := 0
|
||||||
|
for i < len(s) && s[i] != '.' && s[i] != '[' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return s[:i], s[i:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi(s string) (int, error) {
|
||||||
|
n := 0
|
||||||
|
if s == "" {
|
||||||
|
return 0, fmt.Errorf("empty index")
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return 0, fmt.Errorf("invalid index %q", s)
|
||||||
|
}
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scalarString(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case json.Number:
|
||||||
|
return string(t)
|
||||||
|
case bool:
|
||||||
|
return fmt.Sprintf("%v", t)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrubStep replaces stored capture values with {{name}} in kept fields.
|
||||||
|
func ScrubStep(store *Store, step *Step) error {
|
||||||
|
if store == nil || step == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pairs := store.replacements()
|
||||||
|
step.Request.Path = replaceAll(step.Request.Path, pairs)
|
||||||
|
step.Request.Query = replaceAll(step.Request.Query, pairs)
|
||||||
|
step.Request.Headers = scrubMap(step.Request.Headers, pairs)
|
||||||
|
step.Request.Body = Body(replaceAll(string(step.Request.Body), pairs))
|
||||||
|
step.Response.Headers = scrubMap(step.Response.Headers, pairs)
|
||||||
|
step.Response.Body = Body(replaceAll(string(step.Response.Body), pairs))
|
||||||
|
return rejectUnclassifiedCredentials(*step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) replacements() [][2]string {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
keys := make([]string, 0, len(s.vals))
|
||||||
|
for k, v := range s.vals {
|
||||||
|
if v != "" {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
return len(s.vals[keys[i]]) > len(s.vals[keys[j]])
|
||||||
|
})
|
||||||
|
out := make([][2]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, [2]string{s.vals[k], "{{" + k + "}}"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrubMap(in map[string]string, pairs [][2]string) map[string]string {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(in))
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = replaceAll(v, pairs)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceAll(s string, pairs [][2]string) string {
|
||||||
|
for _, p := range pairs {
|
||||||
|
if p[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s = strings.ReplaceAll(s, p[0], p[1])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectUnclassifiedCredentials(step Step) error {
|
||||||
|
check := func(label, s string) error {
|
||||||
|
if hit := remainingCredential(s); hit != "" {
|
||||||
|
return fmt.Errorf("unclassified credential-shaped value (%s) in %s step %s", hit, label, step.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for k, v := range step.Request.Headers {
|
||||||
|
if err := check("request header "+k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := check("request query", step.Request.Query); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := check("request body", string(step.Request.Body)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for k, v := range step.Response.Headers {
|
||||||
|
if err := check("response header "+k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return check("response body", string(step.Response.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func remainingCredential(s string) string {
|
||||||
|
s = placeholderRe.ReplaceAllString(s, "")
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if jwtShapeRe.MatchString(s) {
|
||||||
|
return "jwt"
|
||||||
|
}
|
||||||
|
if invShapeRe.MatchString(s) {
|
||||||
|
return "token"
|
||||||
|
}
|
||||||
|
if cookieRe.MatchString(s) {
|
||||||
|
return "cookie"
|
||||||
|
}
|
||||||
|
if secretFormRe.MatchString(s) {
|
||||||
|
return "oauth_secret"
|
||||||
|
}
|
||||||
|
if pkceFormRe.MatchString(s) {
|
||||||
|
return "pkce"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func varsOutsideFixtures(varsPath, fixtures string) error {
|
||||||
|
if varsPath == "" || fixtures == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
absVars, err := filepath.Abs(varsPath)
|
||||||
|
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", varsPath, fixtures)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeRouteCaptures(step *Step, rules Rules) {
|
||||||
|
if step == nil || len(step.Capture) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
route := rules.Match(step.Request.Method, step.Request.Path)
|
||||||
|
if route != nil && len(route.Capture) > 0 {
|
||||||
|
step.Capture = append([]CaptureRule(nil), route.Capture...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordedResponseHeaders(h http.Header, rules Rules, method, path string) map[string]string {
|
||||||
|
route := rules.Match(method, path)
|
||||||
|
if keep := rules.responseHeaders(route); len(keep) > 0 {
|
||||||
|
return filterHeaders(h, keep)
|
||||||
|
}
|
||||||
|
return keepResponseHeaders(h)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user