imprint-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/examples/discoverandgo/README.md +57 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
- package/examples/echo/README.md +37 -0
- package/examples/echo/echo_test/index.ts +31 -0
- package/examples/google-flights/search_google_flights/index.ts +101 -0
- package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
- package/examples/google-flights/search_google_flights/parser.ts +189 -0
- package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
- package/examples/google-flights/search_google_flights/workflow.json +48 -0
- package/examples/google-hotels/search_google_hotels/index.ts +194 -0
- package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
- package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
- package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
- package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
- package/examples/southwest/README.md +81 -0
- package/examples/southwest/search_southwest_flights/backends.json +23 -0
- package/examples/southwest/search_southwest_flights/cron.json +19 -0
- package/examples/southwest/search_southwest_flights/index.ts +110 -0
- package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
- package/examples/southwest/search_southwest_flights/workflow.json +54 -0
- package/package.json +78 -0
- package/prompts/compile-agent.md +580 -0
- package/prompts/intent-detection.md +198 -0
- package/prompts/playbook-compilation.md +279 -0
- package/prompts/request-triage.md +74 -0
- package/prompts/tool-candidate-detection.md +104 -0
- package/src/cli.ts +1287 -0
- package/src/imprint/agent.ts +468 -0
- package/src/imprint/app-api-hosts.ts +53 -0
- package/src/imprint/backend-ladder.ts +568 -0
- package/src/imprint/check.ts +136 -0
- package/src/imprint/chromium.ts +211 -0
- package/src/imprint/claude-cli-compile.ts +640 -0
- package/src/imprint/cli-credential.ts +394 -0
- package/src/imprint/codex-cli-compile.ts +712 -0
- package/src/imprint/compile-agent-types.ts +40 -0
- package/src/imprint/compile-agent.ts +404 -0
- package/src/imprint/compile-tools.ts +1389 -0
- package/src/imprint/compile.ts +720 -0
- package/src/imprint/cookie-jar.ts +246 -0
- package/src/imprint/credential-bundle.ts +195 -0
- package/src/imprint/credential-extract.ts +290 -0
- package/src/imprint/credential-store.ts +707 -0
- package/src/imprint/cron.ts +312 -0
- package/src/imprint/doctor.ts +223 -0
- package/src/imprint/emit.ts +154 -0
- package/src/imprint/etld.ts +134 -0
- package/src/imprint/freeform-redact.ts +216 -0
- package/src/imprint/inject-listener.ts +137 -0
- package/src/imprint/install.ts +795 -0
- package/src/imprint/integrations.ts +385 -0
- package/src/imprint/is-compiled.ts +2 -0
- package/src/imprint/json-path.ts +100 -0
- package/src/imprint/llm.ts +998 -0
- package/src/imprint/load-json.ts +54 -0
- package/src/imprint/log.ts +33 -0
- package/src/imprint/login.ts +166 -0
- package/src/imprint/mcp-compile-server.ts +282 -0
- package/src/imprint/mcp-maintenance.ts +1790 -0
- package/src/imprint/mcp-server.ts +350 -0
- package/src/imprint/multi-progress.ts +69 -0
- package/src/imprint/notify.ts +155 -0
- package/src/imprint/paths.ts +64 -0
- package/src/imprint/playbook-parser.ts +21 -0
- package/src/imprint/playbook-runner.ts +465 -0
- package/src/imprint/probe-backends.ts +251 -0
- package/src/imprint/progress.ts +28 -0
- package/src/imprint/record.ts +470 -0
- package/src/imprint/redact.ts +550 -0
- package/src/imprint/replay-capture.ts +387 -0
- package/src/imprint/request-context.ts +66 -0
- package/src/imprint/runtime-link.ts +73 -0
- package/src/imprint/runtime.ts +942 -0
- package/src/imprint/sensitive-keys.ts +156 -0
- package/src/imprint/session-diff.ts +409 -0
- package/src/imprint/session-merge.ts +198 -0
- package/src/imprint/session-writer.ts +149 -0
- package/src/imprint/sites.ts +27 -0
- package/src/imprint/stealth-fetch.ts +434 -0
- package/src/imprint/teach-state.ts +235 -0
- package/src/imprint/teach.ts +2120 -0
- package/src/imprint/tool-candidates.ts +423 -0
- package/src/imprint/tool-loader.ts +186 -0
- package/src/imprint/tool-selection.ts +70 -0
- package/src/imprint/tracing.ts +508 -0
- package/src/imprint/types.ts +472 -0
- package/src/imprint/version.ts +21 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared list of sensitive credential key names. Used by `redact.ts` to scrub
|
|
3
|
+
* values, and by `credential-extract.ts` to detect login pairs.
|
|
4
|
+
*
|
|
5
|
+
* Case-insensitive; underscores and hyphens are stripped before matching, so
|
|
6
|
+
* `password`, `Pass_Word`, `PASS-WORD`, `pwd` all match.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SENSITIVE_KEYS = [
|
|
10
|
+
// Credentials — login identifiers
|
|
11
|
+
'user',
|
|
12
|
+
'username',
|
|
13
|
+
'user_name',
|
|
14
|
+
'userid',
|
|
15
|
+
'user_id',
|
|
16
|
+
'login',
|
|
17
|
+
'loginid',
|
|
18
|
+
'login_id',
|
|
19
|
+
// Credentials — passwords & secrets
|
|
20
|
+
'pass',
|
|
21
|
+
'password',
|
|
22
|
+
'passwd',
|
|
23
|
+
'pwd',
|
|
24
|
+
'pin',
|
|
25
|
+
'secret',
|
|
26
|
+
'credential',
|
|
27
|
+
'credentials',
|
|
28
|
+
// Tokens & session identifiers
|
|
29
|
+
'token',
|
|
30
|
+
'auth',
|
|
31
|
+
'authcode',
|
|
32
|
+
'auth_code',
|
|
33
|
+
'apikey',
|
|
34
|
+
'api_key',
|
|
35
|
+
'apitoken',
|
|
36
|
+
'api_token',
|
|
37
|
+
'accesstoken',
|
|
38
|
+
'access_token',
|
|
39
|
+
'refreshtoken',
|
|
40
|
+
'refresh_token',
|
|
41
|
+
'idtoken',
|
|
42
|
+
'id_token',
|
|
43
|
+
'sessionid',
|
|
44
|
+
'session_id',
|
|
45
|
+
'sessiontoken',
|
|
46
|
+
'session_token',
|
|
47
|
+
'authorization',
|
|
48
|
+
'authentication',
|
|
49
|
+
'bearer',
|
|
50
|
+
// CSRF / XSRF
|
|
51
|
+
'csrf',
|
|
52
|
+
'csrf_token',
|
|
53
|
+
'csrftoken',
|
|
54
|
+
'xsrf',
|
|
55
|
+
'xsrf_token',
|
|
56
|
+
'xsrftoken',
|
|
57
|
+
// MFA / OTP
|
|
58
|
+
'otp',
|
|
59
|
+
'totp',
|
|
60
|
+
'mfa_code',
|
|
61
|
+
'mfacode',
|
|
62
|
+
'verification_code',
|
|
63
|
+
'verificationcode',
|
|
64
|
+
'oktaemail',
|
|
65
|
+
'okta_email',
|
|
66
|
+
// Device / browser fingerprinting
|
|
67
|
+
'fingerprint',
|
|
68
|
+
// Site-specific (Discover & Go uses these)
|
|
69
|
+
'patronpassword',
|
|
70
|
+
'patron_password',
|
|
71
|
+
'patronnumber',
|
|
72
|
+
'patron_number',
|
|
73
|
+
'cardnumber',
|
|
74
|
+
'card_number',
|
|
75
|
+
'librarycard',
|
|
76
|
+
'library_card',
|
|
77
|
+
// Stripe / payments
|
|
78
|
+
'cvc',
|
|
79
|
+
'cvv',
|
|
80
|
+
'cardnum',
|
|
81
|
+
'card_num',
|
|
82
|
+
'creditcard',
|
|
83
|
+
'credit_card',
|
|
84
|
+
'cc_number',
|
|
85
|
+
// PII — contact
|
|
86
|
+
'email',
|
|
87
|
+
'emailaddress',
|
|
88
|
+
'email_address',
|
|
89
|
+
'phone',
|
|
90
|
+
'phonenumber',
|
|
91
|
+
'phone_number',
|
|
92
|
+
'mobile',
|
|
93
|
+
'cell',
|
|
94
|
+
'sms',
|
|
95
|
+
'smsnumber',
|
|
96
|
+
'sms_number',
|
|
97
|
+
// PII — names
|
|
98
|
+
'firstname',
|
|
99
|
+
'first_name',
|
|
100
|
+
'lastname',
|
|
101
|
+
'last_name',
|
|
102
|
+
'fullname',
|
|
103
|
+
'full_name',
|
|
104
|
+
'nameoncard',
|
|
105
|
+
'name_on_card',
|
|
106
|
+
// PII — government / identity
|
|
107
|
+
'ssn',
|
|
108
|
+
'socialsecurity',
|
|
109
|
+
'social_security',
|
|
110
|
+
'dateofbirth',
|
|
111
|
+
'date_of_birth',
|
|
112
|
+
'dob',
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const SENSITIVE_KEY_SET = new Set(SENSITIVE_KEYS.map((k) => k.toLowerCase()));
|
|
116
|
+
|
|
117
|
+
/** Subset of SENSITIVE_KEYS that specifically denote a credential (not PII).
|
|
118
|
+
* Used by credential-extract.ts when looking for the password half of a
|
|
119
|
+
* login form pair — we don't want to treat e.g. `dob` as a password. */
|
|
120
|
+
const PASSWORD_LIKE_KEYS = new Set(
|
|
121
|
+
['password', 'passwd', 'pwd', 'pin', 'patronpassword', 'patron_password'].map((k) =>
|
|
122
|
+
k.toLowerCase(),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const SENSITIVE_HEADERS = [
|
|
127
|
+
'authorization',
|
|
128
|
+
'cookie',
|
|
129
|
+
'set-cookie',
|
|
130
|
+
'x-auth-token',
|
|
131
|
+
'x-api-key',
|
|
132
|
+
'x-apikey',
|
|
133
|
+
'x-csrf-token',
|
|
134
|
+
'x-xsrf-token',
|
|
135
|
+
'x-session-token',
|
|
136
|
+
'proxy-authorization',
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const SENSITIVE_HEADER_SET = new Set(SENSITIVE_HEADERS.map((h) => h.toLowerCase()));
|
|
140
|
+
|
|
141
|
+
export const normalizeKey = (s: string): string => s.toLowerCase().replace(/[-_]/g, '');
|
|
142
|
+
|
|
143
|
+
/** True if the key name suggests a sensitive value (auth, payment, PII). */
|
|
144
|
+
export function isSensitiveKey(key: string): boolean {
|
|
145
|
+
return SENSITIVE_KEY_SET.has(normalizeKey(key));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True if the key name suggests a *password* specifically (not arbitrary
|
|
149
|
+
* PII). Used when pairing a username + password in extraction. */
|
|
150
|
+
export function isSensitiveCredentialKey(key: string): boolean {
|
|
151
|
+
return PASSWORD_LIKE_KEYS.has(normalizeKey(key));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isSensitiveHeader(header: string): boolean {
|
|
155
|
+
return SENSITIVE_HEADER_SET.has(header.toLowerCase());
|
|
156
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual-pass session diff: aligns requests from two independent executions
|
|
3
|
+
* and classifies values as constant, server-derived, or browser-minted.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CapturedRequest, Session } from './types.ts';
|
|
7
|
+
|
|
8
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export type ValueClassification = 'constant' | 'browser_minted' | 'server_derived';
|
|
11
|
+
|
|
12
|
+
export interface ClassifiedValue {
|
|
13
|
+
classification: ValueClassification;
|
|
14
|
+
/** e.g. "url_param:correlationId", "header:x-csrf-token", "body:$.transaction.id" */
|
|
15
|
+
location: string;
|
|
16
|
+
originalSeq: number;
|
|
17
|
+
value1: string;
|
|
18
|
+
value2: string;
|
|
19
|
+
/** For server_derived: seq of the response that produced this value in run 2. */
|
|
20
|
+
producerSeq?: number;
|
|
21
|
+
/** For server_derived: where in the producer response the value was found. */
|
|
22
|
+
producerPath?: string;
|
|
23
|
+
suggestedStateName?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface AlignedRequestPair {
|
|
27
|
+
originalSeq: number;
|
|
28
|
+
replaySeq: number;
|
|
29
|
+
/** 0–1 based on URL path, method, body structure similarity. */
|
|
30
|
+
confidence: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DiffResult {
|
|
34
|
+
classifications: ClassifiedValue[];
|
|
35
|
+
alignedPairs: AlignedRequestPair[];
|
|
36
|
+
unmatchedOriginal: number[];
|
|
37
|
+
unmatchedReplay: number[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CapturedReplayRequest {
|
|
41
|
+
seq: number;
|
|
42
|
+
timestamp: number;
|
|
43
|
+
method: string;
|
|
44
|
+
url: string;
|
|
45
|
+
headers: Record<string, string>;
|
|
46
|
+
body?: string;
|
|
47
|
+
resourceType: string;
|
|
48
|
+
response?: {
|
|
49
|
+
status: number;
|
|
50
|
+
headers: Record<string, string>;
|
|
51
|
+
body?: string;
|
|
52
|
+
mimeType?: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Alignment ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
interface RequestLike {
|
|
59
|
+
seq: number;
|
|
60
|
+
method: string;
|
|
61
|
+
url: string;
|
|
62
|
+
headers: Record<string, string>;
|
|
63
|
+
body?: string;
|
|
64
|
+
response?: { status: number; headers: Record<string, string>; body?: string };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function urlPathname(raw: string): string {
|
|
68
|
+
try {
|
|
69
|
+
const u = new URL(raw);
|
|
70
|
+
return `${u.hostname}${u.pathname}`;
|
|
71
|
+
} catch {
|
|
72
|
+
return raw;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function alignmentKey(req: RequestLike): string {
|
|
77
|
+
return `${req.method}\t${urlPathname(req.url)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function jsonKeySet(body: string | undefined): Set<string> | null {
|
|
81
|
+
if (!body) return null;
|
|
82
|
+
try {
|
|
83
|
+
const obj = JSON.parse(body);
|
|
84
|
+
if (typeof obj !== 'object' || obj === null) return null;
|
|
85
|
+
return new Set(Object.keys(obj));
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function keySetSimilarity(a: Set<string> | null, b: Set<string> | null): number {
|
|
92
|
+
if (!a || !b || a.size === 0 || b.size === 0) return 0;
|
|
93
|
+
let intersection = 0;
|
|
94
|
+
for (const k of a) if (b.has(k)) intersection++;
|
|
95
|
+
return intersection / Math.max(a.size, b.size);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Align requests from two runs by (method, URL_pathname, relative_sequence).
|
|
100
|
+
* Returns pairs with confidence scores. Low-confidence pairs (< 0.3) excluded.
|
|
101
|
+
*/
|
|
102
|
+
export function alignRequests(run1: RequestLike[], run2: RequestLike[]): AlignedRequestPair[] {
|
|
103
|
+
const groups1 = new Map<string, RequestLike[]>();
|
|
104
|
+
const groups2 = new Map<string, RequestLike[]>();
|
|
105
|
+
|
|
106
|
+
for (const r of run1) {
|
|
107
|
+
const key = alignmentKey(r);
|
|
108
|
+
const arr = groups1.get(key) ?? [];
|
|
109
|
+
arr.push(r);
|
|
110
|
+
groups1.set(key, arr);
|
|
111
|
+
}
|
|
112
|
+
for (const r of run2) {
|
|
113
|
+
const key = alignmentKey(r);
|
|
114
|
+
const arr = groups2.get(key) ?? [];
|
|
115
|
+
arr.push(r);
|
|
116
|
+
groups2.set(key, arr);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pairs: AlignedRequestPair[] = [];
|
|
120
|
+
const usedRun2Seqs = new Set<number>();
|
|
121
|
+
|
|
122
|
+
for (const [key, g1] of groups1) {
|
|
123
|
+
const g2 = groups2.get(key);
|
|
124
|
+
if (!g2) continue;
|
|
125
|
+
|
|
126
|
+
// Match by relative position within group
|
|
127
|
+
for (let i = 0; i < g1.length; i++) {
|
|
128
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by loop condition
|
|
129
|
+
const r1 = g1[i]!;
|
|
130
|
+
if (i < g2.length) {
|
|
131
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by loop condition
|
|
132
|
+
const r2 = g2[i]!;
|
|
133
|
+
if (usedRun2Seqs.has(r2.seq)) continue;
|
|
134
|
+
|
|
135
|
+
let confidence = 0.7; // base for method+path match at same position
|
|
136
|
+
const bodySim = keySetSimilarity(jsonKeySet(r1.body), jsonKeySet(r2.body));
|
|
137
|
+
if (bodySim > 0) confidence = Math.min(1.0, confidence + bodySim * 0.3);
|
|
138
|
+
|
|
139
|
+
if (confidence >= 0.3) {
|
|
140
|
+
pairs.push({ originalSeq: r1.seq, replaySeq: r2.seq, confidence });
|
|
141
|
+
usedRun2Seqs.add(r2.seq);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return pairs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Value extraction ───────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const SKIP_HEADERS = new Set([
|
|
153
|
+
'user-agent',
|
|
154
|
+
'accept',
|
|
155
|
+
'accept-language',
|
|
156
|
+
'accept-encoding',
|
|
157
|
+
'connection',
|
|
158
|
+
'host',
|
|
159
|
+
'sec-fetch-dest',
|
|
160
|
+
'sec-fetch-mode',
|
|
161
|
+
'sec-fetch-site',
|
|
162
|
+
'sec-ch-ua',
|
|
163
|
+
'sec-ch-ua-mobile',
|
|
164
|
+
'sec-ch-ua-platform',
|
|
165
|
+
'upgrade-insecure-requests',
|
|
166
|
+
'cache-control',
|
|
167
|
+
'pragma',
|
|
168
|
+
'content-length',
|
|
169
|
+
'content-type',
|
|
170
|
+
'origin',
|
|
171
|
+
'referer',
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
interface ExtractedValue {
|
|
175
|
+
location: string;
|
|
176
|
+
value: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function extractUrlParams(url: string): ExtractedValue[] {
|
|
180
|
+
try {
|
|
181
|
+
const u = new URL(url);
|
|
182
|
+
const result: ExtractedValue[] = [];
|
|
183
|
+
for (const [key, val] of u.searchParams) {
|
|
184
|
+
result.push({ location: `url_param:${key}`, value: val });
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
} catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractHeaderValues(headers: Record<string, string>): ExtractedValue[] {
|
|
193
|
+
const result: ExtractedValue[] = [];
|
|
194
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
195
|
+
if (!SKIP_HEADERS.has(name.toLowerCase())) {
|
|
196
|
+
result.push({ location: `header:${name}`, value });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractJsonBodyValues(body: string | undefined, prefix = 'body'): ExtractedValue[] {
|
|
203
|
+
if (!body) return [];
|
|
204
|
+
try {
|
|
205
|
+
const obj = JSON.parse(body);
|
|
206
|
+
return flattenObject(obj, prefix);
|
|
207
|
+
} catch {
|
|
208
|
+
return [{ location: prefix, value: body }];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function flattenObject(obj: unknown, prefix: string): ExtractedValue[] {
|
|
213
|
+
if (obj === null || obj === undefined) return [];
|
|
214
|
+
if (typeof obj !== 'object') {
|
|
215
|
+
return [{ location: prefix, value: String(obj) }];
|
|
216
|
+
}
|
|
217
|
+
const result: ExtractedValue[] = [];
|
|
218
|
+
if (Array.isArray(obj)) {
|
|
219
|
+
for (let i = 0; i < obj.length; i++) {
|
|
220
|
+
result.push(...flattenObject(obj[i], `${prefix}[${i}]`));
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
|
|
224
|
+
result.push(...flattenObject(val, `${prefix}.${key}`));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function extractRequestValues(req: RequestLike): ExtractedValue[] {
|
|
231
|
+
return [
|
|
232
|
+
...extractUrlParams(req.url),
|
|
233
|
+
...extractHeaderValues(req.headers),
|
|
234
|
+
...extractJsonBodyValues(req.body),
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Producer search ────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
interface ProducerMatch {
|
|
241
|
+
seq: number;
|
|
242
|
+
path: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function searchPriorResponses(
|
|
246
|
+
value: string,
|
|
247
|
+
requests: RequestLike[],
|
|
248
|
+
beforeSeq: number,
|
|
249
|
+
): ProducerMatch | null {
|
|
250
|
+
if (value.length < 4) return null; // skip trivially short values
|
|
251
|
+
|
|
252
|
+
for (let i = requests.length - 1; i >= 0; i--) {
|
|
253
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded by loop condition
|
|
254
|
+
const req = requests[i]!;
|
|
255
|
+
if (req.seq >= beforeSeq) continue;
|
|
256
|
+
const resp = req.response;
|
|
257
|
+
if (!resp) continue;
|
|
258
|
+
|
|
259
|
+
// Search response body
|
|
260
|
+
if (resp.body?.includes(value)) {
|
|
261
|
+
const path = findJsonPath(resp.body, value);
|
|
262
|
+
return { seq: req.seq, path: path ?? 'body(substring)' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Search response headers
|
|
266
|
+
for (const [hName, hVal] of Object.entries(resp.headers)) {
|
|
267
|
+
if (hVal.includes(value)) {
|
|
268
|
+
return { seq: req.seq, path: `response_header:${hName}` };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function findJsonPath(body: string, value: string): string | null {
|
|
276
|
+
try {
|
|
277
|
+
const obj = JSON.parse(body);
|
|
278
|
+
return findInObject(obj, value, '$');
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function findInObject(obj: unknown, target: string, path: string): string | null {
|
|
285
|
+
if (obj === null || obj === undefined) return null;
|
|
286
|
+
if (typeof obj === 'string' && obj === target) return path;
|
|
287
|
+
if (typeof obj === 'number' && String(obj) === target) return path;
|
|
288
|
+
if (typeof obj !== 'object') return null;
|
|
289
|
+
|
|
290
|
+
if (Array.isArray(obj)) {
|
|
291
|
+
for (let i = 0; i < obj.length; i++) {
|
|
292
|
+
const found = findInObject(obj[i], target, `${path}[${i}]`);
|
|
293
|
+
if (found) return found;
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
|
|
297
|
+
const found = findInObject(val, target, `${path}.${key}`);
|
|
298
|
+
if (found) return found;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── State name suggestion ──────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
function suggestStateName(location: string): string {
|
|
307
|
+
// url_param:context.correlationIdentifier → correlation_identifier
|
|
308
|
+
// header:x-csrf-token → csrf_token
|
|
309
|
+
// body.transaction.id → transaction_id
|
|
310
|
+
const raw = location.replace(/^(url_param|header|body):?/, '').replace(/^x-/, '');
|
|
311
|
+
|
|
312
|
+
return raw
|
|
313
|
+
.replace(/[.\[\]]/g, '_')
|
|
314
|
+
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
|
315
|
+
.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
316
|
+
.replace(/_+/g, '_')
|
|
317
|
+
.replace(/^_|_$/g, '')
|
|
318
|
+
.toLowerCase();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Main diff ──────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
export function diffTriagedSessions(
|
|
324
|
+
original: Session,
|
|
325
|
+
replay: { requests: CapturedReplayRequest[] },
|
|
326
|
+
): DiffResult {
|
|
327
|
+
const pairs = alignRequests(original.requests, replay.requests);
|
|
328
|
+
const pairedOrigSeqs = new Set(pairs.map((p) => p.originalSeq));
|
|
329
|
+
const pairedReplaySeqs = new Set(pairs.map((p) => p.replaySeq));
|
|
330
|
+
|
|
331
|
+
const classifications: ClassifiedValue[] = [];
|
|
332
|
+
|
|
333
|
+
for (const pair of pairs) {
|
|
334
|
+
if (pair.confidence < 0.5) continue;
|
|
335
|
+
|
|
336
|
+
const r1 = original.requests.find((r) => r.seq === pair.originalSeq);
|
|
337
|
+
const r2 = replay.requests.find((r) => r.seq === pair.replaySeq);
|
|
338
|
+
if (!r1 || !r2) continue;
|
|
339
|
+
|
|
340
|
+
const vals1 = extractRequestValues(r1);
|
|
341
|
+
const vals2 = extractRequestValues(r2);
|
|
342
|
+
|
|
343
|
+
const map2 = new Map(vals2.map((v) => [v.location, v.value]));
|
|
344
|
+
|
|
345
|
+
for (const v1 of vals1) {
|
|
346
|
+
const v2Value = map2.get(v1.location);
|
|
347
|
+
if (v2Value === undefined) continue; // field only in run 1
|
|
348
|
+
|
|
349
|
+
if (v1.value === v2Value) {
|
|
350
|
+
classifications.push({
|
|
351
|
+
classification: 'constant',
|
|
352
|
+
location: v1.location,
|
|
353
|
+
originalSeq: pair.originalSeq,
|
|
354
|
+
value1: v1.value,
|
|
355
|
+
value2: v2Value,
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Value differs — check if it came from a prior response in run 2
|
|
361
|
+
const producer = searchPriorResponses(v2Value, replay.requests, pair.replaySeq);
|
|
362
|
+
|
|
363
|
+
if (producer) {
|
|
364
|
+
const name = suggestStateName(v1.location);
|
|
365
|
+
classifications.push({
|
|
366
|
+
classification: 'server_derived',
|
|
367
|
+
location: v1.location,
|
|
368
|
+
originalSeq: pair.originalSeq,
|
|
369
|
+
value1: v1.value,
|
|
370
|
+
value2: v2Value,
|
|
371
|
+
producerSeq: producer.seq,
|
|
372
|
+
producerPath: producer.path,
|
|
373
|
+
suggestedStateName: name || undefined,
|
|
374
|
+
});
|
|
375
|
+
} else {
|
|
376
|
+
const name = suggestStateName(v1.location);
|
|
377
|
+
classifications.push({
|
|
378
|
+
classification: 'browser_minted',
|
|
379
|
+
location: v1.location,
|
|
380
|
+
originalSeq: pair.originalSeq,
|
|
381
|
+
value1: v1.value,
|
|
382
|
+
value2: v2Value,
|
|
383
|
+
suggestedStateName: name || undefined,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
classifications,
|
|
391
|
+
alignedPairs: pairs,
|
|
392
|
+
unmatchedOriginal: original.requests
|
|
393
|
+
.filter((r) => !pairedOrigSeqs.has(r.seq))
|
|
394
|
+
.map((r) => r.seq),
|
|
395
|
+
unmatchedReplay: replay.requests.filter((r) => !pairedReplaySeqs.has(r.seq)).map((r) => r.seq),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Triage run-2 requests by aligning them against run-1's already-triaged set.
|
|
401
|
+
* No narration or LLM call needed — the first triage acts as the oracle.
|
|
402
|
+
*/
|
|
403
|
+
export function triageByAlignment(
|
|
404
|
+
run1TriagedRequests: CapturedRequest[],
|
|
405
|
+
run2AllRequests: CapturedReplayRequest[],
|
|
406
|
+
): number[] {
|
|
407
|
+
const aligned = alignRequests(run1TriagedRequests, run2AllRequests);
|
|
408
|
+
return aligned.filter((pair) => pair.confidence >= 0.5).map((pair) => pair.replaySeq);
|
|
409
|
+
}
|