leapfrog-mcp 0.6.2 → 0.6.3
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/dist/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Session } from "./types.js";
|
|
2
|
+
export type ApiCategory = "data" | "tracking" | "auth" | "cdn" | "ads";
|
|
3
|
+
export interface ApiCapture {
|
|
4
|
+
index: number;
|
|
5
|
+
url: string;
|
|
6
|
+
method: string;
|
|
7
|
+
status: number;
|
|
8
|
+
bodySize: number;
|
|
9
|
+
parsedBody?: unknown;
|
|
10
|
+
rawBody?: string;
|
|
11
|
+
bodyTruncated?: boolean;
|
|
12
|
+
category: ApiCategory;
|
|
13
|
+
confidence: number;
|
|
14
|
+
classifiedBy: "domain" | "url-pattern" | "response-shape" | "heuristic" | "default";
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
duration: number;
|
|
18
|
+
dataShape?: Record<string, string>;
|
|
19
|
+
graphql?: {
|
|
20
|
+
operationName: string | null;
|
|
21
|
+
operationType: "query" | "mutation" | "subscription" | null;
|
|
22
|
+
};
|
|
23
|
+
contentType: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ApiDiscoverResult {
|
|
26
|
+
total: number;
|
|
27
|
+
captured: ApiCapture[];
|
|
28
|
+
summary: {
|
|
29
|
+
data: number;
|
|
30
|
+
tracking: number;
|
|
31
|
+
auth: number;
|
|
32
|
+
cdn: number;
|
|
33
|
+
ads: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface OpenApiSpec {
|
|
37
|
+
openapi: string;
|
|
38
|
+
info: {
|
|
39
|
+
title: string;
|
|
40
|
+
version: string;
|
|
41
|
+
};
|
|
42
|
+
paths: Record<string, unknown>;
|
|
43
|
+
components?: {
|
|
44
|
+
securitySchemes?: Record<string, unknown>;
|
|
45
|
+
schemas?: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export declare class ApiIntelligence {
|
|
49
|
+
/**
|
|
50
|
+
* Classify a URL + response into a category.
|
|
51
|
+
* Exported for testability.
|
|
52
|
+
*/
|
|
53
|
+
static classify(url: string, headers: Record<string, string>, body: unknown, method: string, bodySize: number): {
|
|
54
|
+
category: ApiCategory;
|
|
55
|
+
confidence: number;
|
|
56
|
+
classifiedBy: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Process a response event and store if it's a JSON API call.
|
|
60
|
+
* Designed to be called from the existing response listener in network-intelligence.ts.
|
|
61
|
+
*/
|
|
62
|
+
static capture(session: Session, url: string, method: string, status: number, headers: Record<string, string>, body: Buffer | null, duration: number, resourceType: string, requestHeaders?: Record<string, string>, requestBody?: string): void;
|
|
63
|
+
/**
|
|
64
|
+
* Get captured API calls for a session, optionally filtered.
|
|
65
|
+
*/
|
|
66
|
+
static discover(sessionId: string, options?: {
|
|
67
|
+
category?: ApiCategory;
|
|
68
|
+
minConfidence?: number;
|
|
69
|
+
}): ApiDiscoverResult;
|
|
70
|
+
/**
|
|
71
|
+
* Generate OpenAPI v3 spec from captured traffic.
|
|
72
|
+
*/
|
|
73
|
+
static exportOpenApi(sessionId: string, options?: {
|
|
74
|
+
title?: string;
|
|
75
|
+
includeTracking?: boolean;
|
|
76
|
+
}): OpenApiSpec;
|
|
77
|
+
/**
|
|
78
|
+
* Clear captures for a session.
|
|
79
|
+
*/
|
|
80
|
+
static clearSession(sessionId: string): void;
|
|
81
|
+
}
|
|
82
|
+
export default ApiIntelligence;
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
3
|
+
const MAX_API_ENTRIES = 50;
|
|
4
|
+
const MAX_API_BODY_BYTES = 256 * 1024;
|
|
5
|
+
const JSON_CONTENT_TYPES = [
|
|
6
|
+
"application/json",
|
|
7
|
+
"application/graphql+json",
|
|
8
|
+
"application/vnd.api+json",
|
|
9
|
+
"application/hal+json",
|
|
10
|
+
"text/json",
|
|
11
|
+
];
|
|
12
|
+
const ELIGIBLE_RESOURCE_TYPES = ["xhr", "fetch"];
|
|
13
|
+
// ─── Domain Blocklists ───────────────────────────────────────────────────────
|
|
14
|
+
const TRACKING_DOMAINS = [
|
|
15
|
+
"google-analytics.com",
|
|
16
|
+
"analytics.google.com",
|
|
17
|
+
"www.googletagmanager.com",
|
|
18
|
+
"stats.g.doubleclick.net",
|
|
19
|
+
"api.segment.io",
|
|
20
|
+
"cdn.segment.com",
|
|
21
|
+
"api.mixpanel.com",
|
|
22
|
+
"api-js.mixpanel.com",
|
|
23
|
+
"connect.facebook.net",
|
|
24
|
+
"www.facebook.com",
|
|
25
|
+
"api2.amplitude.com",
|
|
26
|
+
"cdn.amplitude.com",
|
|
27
|
+
"heapanalytics.com",
|
|
28
|
+
"script.hotjar.com",
|
|
29
|
+
"vars.hotjar.com",
|
|
30
|
+
"edge.fullstory.com",
|
|
31
|
+
"browser-intake-datadoghq.com",
|
|
32
|
+
"sentry.io",
|
|
33
|
+
"bam.nr-data.net",
|
|
34
|
+
"js-agent.newrelic.com",
|
|
35
|
+
"events.launchdarkly.com",
|
|
36
|
+
"clientstream.launchdarkly.com",
|
|
37
|
+
"api-iam.intercom.io",
|
|
38
|
+
"widget.intercom.io",
|
|
39
|
+
];
|
|
40
|
+
const AD_DOMAINS = [
|
|
41
|
+
"doubleclick.net",
|
|
42
|
+
"googlesyndication.com",
|
|
43
|
+
"googleadservices.com",
|
|
44
|
+
"adservice.google.com",
|
|
45
|
+
"amazon-adsystem.com",
|
|
46
|
+
"ads-api.twitter.com",
|
|
47
|
+
"ads.linkedin.com",
|
|
48
|
+
"adsapi.snapchat.com",
|
|
49
|
+
"an.facebook.com",
|
|
50
|
+
"moat.com",
|
|
51
|
+
"adsrvr.org",
|
|
52
|
+
"criteo.com",
|
|
53
|
+
"outbrain.com",
|
|
54
|
+
"taboola.com",
|
|
55
|
+
"pubmatic.com",
|
|
56
|
+
"rubiconproject.com",
|
|
57
|
+
"openx.net",
|
|
58
|
+
"casalemedia.com",
|
|
59
|
+
"sharethrough.com",
|
|
60
|
+
];
|
|
61
|
+
// ─── URL Pattern Classifiers ─────────────────────────────────────────────────
|
|
62
|
+
const URL_CLASSIFIERS = [
|
|
63
|
+
// Auth patterns
|
|
64
|
+
{ pattern: /\/(oauth|auth|login|logout|signin|signup|token|csrf|session|sso)\b/i, category: "auth" },
|
|
65
|
+
{ pattern: /\/(\.well-known\/openid|authorize|callback|refresh)\b/i, category: "auth" },
|
|
66
|
+
// Tracking patterns on first-party domains
|
|
67
|
+
{ pattern: /\/(collect|beacon|pixel|telemetry|ping|heartbeat|log-event)\b/i, category: "tracking" },
|
|
68
|
+
{ pattern: /\/(analytics|tracking|metrics|events\/track)\b/i, category: "tracking" },
|
|
69
|
+
{ pattern: /\/_analytics/i, category: "tracking" },
|
|
70
|
+
// CDN / static config patterns
|
|
71
|
+
{ pattern: /\/(manifest\.json|sw\.js|service-worker|workbox)/i, category: "cdn" },
|
|
72
|
+
{ pattern: /\/(_next\/data|__next|_nuxt)\//i, category: "data" },
|
|
73
|
+
];
|
|
74
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
75
|
+
function isJsonContentType(contentType) {
|
|
76
|
+
const lower = contentType.toLowerCase();
|
|
77
|
+
return JSON_CONTENT_TYPES.some((ct) => lower.startsWith(ct));
|
|
78
|
+
}
|
|
79
|
+
function extractHostname(url) {
|
|
80
|
+
try {
|
|
81
|
+
return new URL(url).hostname;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function matchesDomain(hostname, domains) {
|
|
88
|
+
const lower = hostname.toLowerCase();
|
|
89
|
+
return domains.some((d) => lower === d || lower.endsWith("." + d));
|
|
90
|
+
}
|
|
91
|
+
function isGraphqlUrl(url) {
|
|
92
|
+
try {
|
|
93
|
+
const pathname = new URL(url).pathname;
|
|
94
|
+
return /\/graphql\b/i.test(pathname);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function parseGraphqlRequest(requestBody) {
|
|
101
|
+
if (!requestBody)
|
|
102
|
+
return null;
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(requestBody);
|
|
105
|
+
const queryStr = parsed.query;
|
|
106
|
+
let opType = null;
|
|
107
|
+
if (typeof queryStr === "string") {
|
|
108
|
+
const trimmed = queryStr.trimStart();
|
|
109
|
+
if (trimmed.startsWith("mutation"))
|
|
110
|
+
opType = "mutation";
|
|
111
|
+
else if (trimmed.startsWith("subscription"))
|
|
112
|
+
opType = "subscription";
|
|
113
|
+
else
|
|
114
|
+
opType = "query";
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
operationName: parsed.operationName ?? null,
|
|
118
|
+
operationType: opType,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function inferDataShape(body) {
|
|
126
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
127
|
+
return undefined;
|
|
128
|
+
const shape = {};
|
|
129
|
+
const obj = body;
|
|
130
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
131
|
+
if (value === null)
|
|
132
|
+
shape[key] = "null";
|
|
133
|
+
else if (Array.isArray(value))
|
|
134
|
+
shape[key] = `array(${value.length})`;
|
|
135
|
+
else if (typeof value === "object")
|
|
136
|
+
shape[key] = "object";
|
|
137
|
+
else
|
|
138
|
+
shape[key] = typeof value;
|
|
139
|
+
}
|
|
140
|
+
return Object.keys(shape).length > 0 ? shape : undefined;
|
|
141
|
+
}
|
|
142
|
+
function classifyByShape(body) {
|
|
143
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
144
|
+
return null;
|
|
145
|
+
const obj = body;
|
|
146
|
+
const keys = Object.keys(obj);
|
|
147
|
+
// Auth response shapes
|
|
148
|
+
if (keys.includes("access_token") ||
|
|
149
|
+
keys.includes("refresh_token") ||
|
|
150
|
+
keys.includes("id_token") ||
|
|
151
|
+
keys.includes("csrf_token") ||
|
|
152
|
+
(keys.includes("token") && keys.includes("expires_in"))) {
|
|
153
|
+
return { category: "auth", confidence: 0.8 };
|
|
154
|
+
}
|
|
155
|
+
// Tracking acknowledgment shapes (tiny success-only)
|
|
156
|
+
if (keys.length <= 2 &&
|
|
157
|
+
(keys.includes("success") || keys.includes("ok") || keys.includes("status")) &&
|
|
158
|
+
!keys.some((k) => Array.isArray(obj[k]) || (typeof obj[k] === "object" && obj[k] !== null))) {
|
|
159
|
+
return { category: "tracking", confidence: 0.8 };
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
function classifyByHeuristic(method, bodySize) {
|
|
164
|
+
// POST with tiny response is likely tracking beacon
|
|
165
|
+
if (method === "POST" && bodySize < 100) {
|
|
166
|
+
return { category: "tracking", confidence: 0.6 };
|
|
167
|
+
}
|
|
168
|
+
// GET with substantial JSON is likely data
|
|
169
|
+
if (method === "GET" && bodySize > 500) {
|
|
170
|
+
return { category: "data", confidence: 0.6 };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/** UUID regex: 8-4-4-4-12 hex chars */
|
|
175
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
176
|
+
/** Pure numeric segment */
|
|
177
|
+
const NUMERIC_REGEX = /^\d+$/;
|
|
178
|
+
/** Long hash-like segment (>20 chars, hex or alphanum) */
|
|
179
|
+
const HASH_REGEX = /^[0-9a-f]{21,}$/i;
|
|
180
|
+
function isParameterSegment(segment) {
|
|
181
|
+
if (NUMERIC_REGEX.test(segment))
|
|
182
|
+
return true;
|
|
183
|
+
if (UUID_REGEX.test(segment))
|
|
184
|
+
return true;
|
|
185
|
+
if (segment.length > 20 && HASH_REGEX.test(segment))
|
|
186
|
+
return true;
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
function templatizePath(pathname) {
|
|
190
|
+
return pathname
|
|
191
|
+
.split("/")
|
|
192
|
+
.map((seg) => (seg && isParameterSegment(seg) ? "{id}" : seg))
|
|
193
|
+
.join("/");
|
|
194
|
+
}
|
|
195
|
+
// ─── Module-Level Storage ────────────────────────────────────────────────────
|
|
196
|
+
const captureStore = new Map();
|
|
197
|
+
let globalIndex = 0;
|
|
198
|
+
function getCaptures(sessionId) {
|
|
199
|
+
let arr = captureStore.get(sessionId);
|
|
200
|
+
if (!arr) {
|
|
201
|
+
arr = [];
|
|
202
|
+
captureStore.set(sessionId, arr);
|
|
203
|
+
}
|
|
204
|
+
return arr;
|
|
205
|
+
}
|
|
206
|
+
function pushToRingBuffer(buffer, entry) {
|
|
207
|
+
if (buffer.length >= MAX_API_ENTRIES) {
|
|
208
|
+
buffer.shift();
|
|
209
|
+
}
|
|
210
|
+
buffer.push(entry);
|
|
211
|
+
}
|
|
212
|
+
// ─── ApiIntelligence ─────────────────────────────────────────────────────────
|
|
213
|
+
export class ApiIntelligence {
|
|
214
|
+
/**
|
|
215
|
+
* Classify a URL + response into a category.
|
|
216
|
+
* Exported for testability.
|
|
217
|
+
*/
|
|
218
|
+
static classify(url, headers, body, method, bodySize) {
|
|
219
|
+
const hostname = extractHostname(url);
|
|
220
|
+
// Layer 1: Known domain blocklist
|
|
221
|
+
if (matchesDomain(hostname, TRACKING_DOMAINS)) {
|
|
222
|
+
return { category: "tracking", confidence: 1.0, classifiedBy: "domain" };
|
|
223
|
+
}
|
|
224
|
+
if (matchesDomain(hostname, AD_DOMAINS)) {
|
|
225
|
+
return { category: "ads", confidence: 1.0, classifiedBy: "domain" };
|
|
226
|
+
}
|
|
227
|
+
// Layer 2: URL path patterns
|
|
228
|
+
let pathname = "";
|
|
229
|
+
try {
|
|
230
|
+
pathname = new URL(url).pathname;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
/* ignore */
|
|
234
|
+
}
|
|
235
|
+
for (const { pattern, category } of URL_CLASSIFIERS) {
|
|
236
|
+
if (pattern.test(pathname)) {
|
|
237
|
+
return { category, confidence: 0.9, classifiedBy: "url-pattern" };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Layer 3: Response shape analysis
|
|
241
|
+
const shapeResult = classifyByShape(body);
|
|
242
|
+
if (shapeResult) {
|
|
243
|
+
return { ...shapeResult, classifiedBy: "response-shape" };
|
|
244
|
+
}
|
|
245
|
+
// Layer 4: Size/method heuristics
|
|
246
|
+
const heuristicResult = classifyByHeuristic(method, bodySize);
|
|
247
|
+
if (heuristicResult) {
|
|
248
|
+
return { ...heuristicResult, classifiedBy: "heuristic" };
|
|
249
|
+
}
|
|
250
|
+
// Default
|
|
251
|
+
return { category: "data", confidence: 0.5, classifiedBy: "default" };
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Process a response event and store if it's a JSON API call.
|
|
255
|
+
* Designed to be called from the existing response listener in network-intelligence.ts.
|
|
256
|
+
*/
|
|
257
|
+
static capture(session, url, method, status, headers, body, duration, resourceType, requestHeaders, requestBody) {
|
|
258
|
+
try {
|
|
259
|
+
// Skip non-XHR/fetch
|
|
260
|
+
if (!ELIGIBLE_RESOURCE_TYPES.includes(resourceType))
|
|
261
|
+
return;
|
|
262
|
+
// Skip OPTIONS (CORS preflight)
|
|
263
|
+
if (method === "OPTIONS" || method === "HEAD")
|
|
264
|
+
return;
|
|
265
|
+
// Skip non-success responses
|
|
266
|
+
if (status < 200 || status > 399)
|
|
267
|
+
return;
|
|
268
|
+
const contentType = (headers["content-type"] ?? "").toLowerCase();
|
|
269
|
+
if (!isJsonContentType(contentType))
|
|
270
|
+
return;
|
|
271
|
+
const captures = getCaptures(session.id);
|
|
272
|
+
let parsedBody;
|
|
273
|
+
let rawBody;
|
|
274
|
+
let bodyTruncated = false;
|
|
275
|
+
let bodySize = 0;
|
|
276
|
+
if (body) {
|
|
277
|
+
bodySize = body.length;
|
|
278
|
+
if (bodySize > MAX_API_BODY_BYTES) {
|
|
279
|
+
bodyTruncated = true;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const text = body.toString("utf-8");
|
|
283
|
+
try {
|
|
284
|
+
parsedBody = JSON.parse(text);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
rawBody = text;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const classification = ApiIntelligence.classify(url, headers, parsedBody, method, bodySize);
|
|
292
|
+
// GraphQL handling
|
|
293
|
+
let graphql;
|
|
294
|
+
if (isGraphqlUrl(url)) {
|
|
295
|
+
const gqlInfo = parseGraphqlRequest(requestBody);
|
|
296
|
+
if (gqlInfo) {
|
|
297
|
+
graphql = gqlInfo;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Data shape inference for data category
|
|
301
|
+
let dataShape;
|
|
302
|
+
if (classification.category === "data" && parsedBody) {
|
|
303
|
+
dataShape = inferDataShape(parsedBody);
|
|
304
|
+
}
|
|
305
|
+
const entry = {
|
|
306
|
+
index: globalIndex++,
|
|
307
|
+
url,
|
|
308
|
+
method,
|
|
309
|
+
status,
|
|
310
|
+
bodySize,
|
|
311
|
+
...(parsedBody !== undefined ? { parsedBody } : {}),
|
|
312
|
+
...(rawBody !== undefined ? { rawBody } : {}),
|
|
313
|
+
...(bodyTruncated ? { bodyTruncated } : {}),
|
|
314
|
+
category: classification.category,
|
|
315
|
+
confidence: classification.confidence,
|
|
316
|
+
classifiedBy: classification.classifiedBy,
|
|
317
|
+
headers: { ...headers },
|
|
318
|
+
timestamp: Date.now(),
|
|
319
|
+
duration,
|
|
320
|
+
...(dataShape ? { dataShape } : {}),
|
|
321
|
+
...(graphql ? { graphql } : {}),
|
|
322
|
+
contentType: contentType.split(";")[0].trim(),
|
|
323
|
+
};
|
|
324
|
+
// Also merge request headers for auth detection in export
|
|
325
|
+
if (requestHeaders) {
|
|
326
|
+
entry.headers = { ...entry.headers, _requestHeaders: JSON.stringify(requestHeaders) };
|
|
327
|
+
}
|
|
328
|
+
pushToRingBuffer(captures, entry);
|
|
329
|
+
logger.debug("api-intelligence:capture", {
|
|
330
|
+
url,
|
|
331
|
+
category: classification.category,
|
|
332
|
+
confidence: classification.confidence,
|
|
333
|
+
classifiedBy: classification.classifiedBy,
|
|
334
|
+
bodySize,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// Capture must never crash the server
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Get captured API calls for a session, optionally filtered.
|
|
343
|
+
*/
|
|
344
|
+
static discover(sessionId, options) {
|
|
345
|
+
const captures = captureStore.get(sessionId) ?? [];
|
|
346
|
+
let filtered = captures;
|
|
347
|
+
if (options?.category) {
|
|
348
|
+
filtered = filtered.filter((c) => c.category === options.category);
|
|
349
|
+
}
|
|
350
|
+
if (options?.minConfidence !== undefined) {
|
|
351
|
+
filtered = filtered.filter((c) => c.confidence >= options.minConfidence);
|
|
352
|
+
}
|
|
353
|
+
const summary = { data: 0, tracking: 0, auth: 0, cdn: 0, ads: 0 };
|
|
354
|
+
for (const c of captures) {
|
|
355
|
+
summary[c.category]++;
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
total: captures.length,
|
|
359
|
+
captured: filtered,
|
|
360
|
+
summary,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Generate OpenAPI v3 spec from captured traffic.
|
|
365
|
+
*/
|
|
366
|
+
static exportOpenApi(sessionId, options) {
|
|
367
|
+
const captures = captureStore.get(sessionId) ?? [];
|
|
368
|
+
const title = options?.title ?? "Discovered API";
|
|
369
|
+
const includeTracking = options?.includeTracking ?? false;
|
|
370
|
+
// Filter to relevant captures
|
|
371
|
+
const relevant = includeTracking
|
|
372
|
+
? captures
|
|
373
|
+
: captures.filter((c) => c.category === "data" || c.category === "auth");
|
|
374
|
+
// ── Endpoint clustering ──────────────────────────────────────────
|
|
375
|
+
// Group by templatized path + method
|
|
376
|
+
const endpointMap = new Map();
|
|
377
|
+
for (const cap of relevant) {
|
|
378
|
+
let parsed;
|
|
379
|
+
try {
|
|
380
|
+
parsed = new URL(cap.url);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const pathSegments = parsed.pathname.split("/");
|
|
386
|
+
const templateSegments = pathSegments.map((seg) => seg && isParameterSegment(seg) ? "{id}" : seg);
|
|
387
|
+
const template = templateSegments.join("/") || "/";
|
|
388
|
+
const key = `${cap.method}::${template}`;
|
|
389
|
+
let group = endpointMap.get(key);
|
|
390
|
+
if (!group) {
|
|
391
|
+
// Record which segments are parameterized and their first real values
|
|
392
|
+
const pathParams = new Set();
|
|
393
|
+
const exampleValues = new Map();
|
|
394
|
+
for (let i = 0; i < pathSegments.length; i++) {
|
|
395
|
+
if (pathSegments[i] && isParameterSegment(pathSegments[i])) {
|
|
396
|
+
pathParams.add(i);
|
|
397
|
+
exampleValues.set(i, pathSegments[i]);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
group = {
|
|
401
|
+
template,
|
|
402
|
+
method: cap.method.toLowerCase(),
|
|
403
|
+
captures: [],
|
|
404
|
+
pathParams,
|
|
405
|
+
exampleValues,
|
|
406
|
+
queryParams: new Map(),
|
|
407
|
+
};
|
|
408
|
+
endpointMap.set(key, group);
|
|
409
|
+
}
|
|
410
|
+
group.captures.push(cap);
|
|
411
|
+
// Collect query params from the first observation
|
|
412
|
+
if (group.queryParams.size === 0) {
|
|
413
|
+
for (const [k, v] of parsed.searchParams.entries()) {
|
|
414
|
+
group.queryParams.set(k, v);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// ── Build paths ──────────────────────────────────────────────────
|
|
419
|
+
const paths = {};
|
|
420
|
+
for (const [, group] of endpointMap) {
|
|
421
|
+
const pathKey = group.template;
|
|
422
|
+
if (!paths[pathKey])
|
|
423
|
+
paths[pathKey] = {};
|
|
424
|
+
const parameters = [];
|
|
425
|
+
// Path parameters
|
|
426
|
+
for (const [idx, example] of group.exampleValues) {
|
|
427
|
+
parameters.push({
|
|
428
|
+
name: "id",
|
|
429
|
+
in: "path",
|
|
430
|
+
required: true,
|
|
431
|
+
schema: { type: "string" },
|
|
432
|
+
example,
|
|
433
|
+
});
|
|
434
|
+
// Only push one param named "id" even if multiple segments
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
// Query parameters
|
|
438
|
+
for (const [name, example] of group.queryParams) {
|
|
439
|
+
parameters.push({
|
|
440
|
+
name,
|
|
441
|
+
in: "query",
|
|
442
|
+
schema: { type: "string" },
|
|
443
|
+
example,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
// Response schema from first capture with a parsed body
|
|
447
|
+
const sampleCapture = group.captures.find((c) => c.parsedBody !== undefined);
|
|
448
|
+
const responseSchema = sampleCapture?.parsedBody
|
|
449
|
+
? inferJsonSchema(sampleCapture.parsedBody)
|
|
450
|
+
: { type: "object" };
|
|
451
|
+
const responses = {};
|
|
452
|
+
// Use the most common status code
|
|
453
|
+
const statusCounts = new Map();
|
|
454
|
+
for (const c of group.captures) {
|
|
455
|
+
statusCounts.set(c.status, (statusCounts.get(c.status) ?? 0) + 1);
|
|
456
|
+
}
|
|
457
|
+
const primaryStatus = [...statusCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 200;
|
|
458
|
+
responses[String(primaryStatus)] = {
|
|
459
|
+
description: "Observed response",
|
|
460
|
+
content: {
|
|
461
|
+
"application/json": {
|
|
462
|
+
schema: responseSchema,
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
const operation = {
|
|
467
|
+
responses,
|
|
468
|
+
...(parameters.length > 0 ? { parameters } : {}),
|
|
469
|
+
};
|
|
470
|
+
// GraphQL annotation
|
|
471
|
+
const gqlCapture = group.captures.find((c) => c.graphql);
|
|
472
|
+
if (gqlCapture?.graphql) {
|
|
473
|
+
operation["x-graphql"] = gqlCapture.graphql;
|
|
474
|
+
}
|
|
475
|
+
paths[pathKey][group.method] = operation;
|
|
476
|
+
}
|
|
477
|
+
// ── Auth detection → security schemes ────────────────────────────
|
|
478
|
+
const securitySchemes = {};
|
|
479
|
+
for (const cap of relevant) {
|
|
480
|
+
const reqHeaders = extractRequestHeaders(cap);
|
|
481
|
+
if (!reqHeaders)
|
|
482
|
+
continue;
|
|
483
|
+
const authHeader = reqHeaders["authorization"] ?? reqHeaders["Authorization"];
|
|
484
|
+
if (authHeader) {
|
|
485
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
486
|
+
securitySchemes["bearerAuth"] = {
|
|
487
|
+
type: "http",
|
|
488
|
+
scheme: "bearer",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
else if (authHeader.startsWith("Basic ")) {
|
|
492
|
+
securitySchemes["basicAuth"] = {
|
|
493
|
+
type: "http",
|
|
494
|
+
scheme: "basic",
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// API key patterns
|
|
499
|
+
for (const header of Object.keys(reqHeaders)) {
|
|
500
|
+
const lower = header.toLowerCase();
|
|
501
|
+
if (lower === "x-api-key" || lower === "api-key") {
|
|
502
|
+
securitySchemes["apiKeyHeader"] = {
|
|
503
|
+
type: "apiKey",
|
|
504
|
+
in: "header",
|
|
505
|
+
name: header,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
if (lower === "x-csrf-token" || lower === "csrf-token") {
|
|
509
|
+
securitySchemes["csrfToken"] = {
|
|
510
|
+
type: "apiKey",
|
|
511
|
+
in: "header",
|
|
512
|
+
name: header,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const spec = {
|
|
518
|
+
openapi: "3.0.3",
|
|
519
|
+
info: { title, version: "1.0.0" },
|
|
520
|
+
paths,
|
|
521
|
+
};
|
|
522
|
+
if (Object.keys(securitySchemes).length > 0) {
|
|
523
|
+
spec.components = { securitySchemes };
|
|
524
|
+
}
|
|
525
|
+
return spec;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Clear captures for a session.
|
|
529
|
+
*/
|
|
530
|
+
static clearSession(sessionId) {
|
|
531
|
+
captureStore.delete(sessionId);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
|
535
|
+
function extractRequestHeaders(cap) {
|
|
536
|
+
const raw = cap.headers["_requestHeaders"];
|
|
537
|
+
if (!raw)
|
|
538
|
+
return null;
|
|
539
|
+
try {
|
|
540
|
+
return JSON.parse(raw);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Infer a minimal JSON Schema object from a runtime value.
|
|
548
|
+
* Used to generate OpenAPI response schemas from observed traffic.
|
|
549
|
+
*/
|
|
550
|
+
function inferJsonSchema(value, depth = 0) {
|
|
551
|
+
if (depth > 3)
|
|
552
|
+
return { type: "object" }; // prevent deep recursion
|
|
553
|
+
if (value === null)
|
|
554
|
+
return { type: "string", nullable: true };
|
|
555
|
+
if (Array.isArray(value)) {
|
|
556
|
+
const items = value.length > 0 ? inferJsonSchema(value[0], depth + 1) : { type: "object" };
|
|
557
|
+
return { type: "array", items };
|
|
558
|
+
}
|
|
559
|
+
if (typeof value === "object") {
|
|
560
|
+
const properties = {};
|
|
561
|
+
const obj = value;
|
|
562
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
563
|
+
properties[k] = inferJsonSchema(v, depth + 1);
|
|
564
|
+
}
|
|
565
|
+
return { type: "object", properties };
|
|
566
|
+
}
|
|
567
|
+
if (typeof value === "number") {
|
|
568
|
+
return Number.isInteger(value) ? { type: "integer" } : { type: "number" };
|
|
569
|
+
}
|
|
570
|
+
if (typeof value === "boolean")
|
|
571
|
+
return { type: "boolean" };
|
|
572
|
+
return { type: "string" };
|
|
573
|
+
}
|
|
574
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
575
|
+
export default ApiIntelligence;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Page } from "playwright-core";
|
|
2
|
+
export type CaptchaProvider = "capsolver" | "2captcha" | "nopecha";
|
|
3
|
+
export type CaptchaType = "recaptcha-v2" | "recaptcha-v3" | "hcaptcha" | "turnstile";
|
|
4
|
+
export interface CaptchaSolveResult {
|
|
5
|
+
solved: boolean;
|
|
6
|
+
provider: string;
|
|
7
|
+
captchaType: string;
|
|
8
|
+
solveTimeMs: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns true if a CAPTCHA solving provider is configured and ready.
|
|
13
|
+
* Safe to call at any time — reads env vars on each invocation so config
|
|
14
|
+
* changes at runtime are picked up immediately.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isCaptchaSolverEnabled(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Attempts to solve a CAPTCHA on the given page using the configured
|
|
19
|
+
* external solving service.
|
|
20
|
+
*
|
|
21
|
+
* @param page - Playwright page with the CAPTCHA
|
|
22
|
+
* @param captchaType - Free-form type string (e.g. "recaptcha", "hcaptcha", "turnstile")
|
|
23
|
+
* @param elementSelector - Optional CSS selector hint for the CAPTCHA element
|
|
24
|
+
* @returns - Result object with solve status, timing, and any error
|
|
25
|
+
*/
|
|
26
|
+
export declare function solveCaptcha(page: Page, captchaType: string, elementSelector?: string): Promise<CaptchaSolveResult>;
|