reverse-api-engineer 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Reverse API - Browser traffic capture for API reverse engineering."""
2
+
3
+ __version__ = "0.1.0"
reverse_api/browser.py ADDED
@@ -0,0 +1,419 @@
1
+ """Browser management with Playwright for HAR recording."""
2
+
3
+ import json
4
+ import random
5
+ import signal
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page
11
+ from playwright_stealth import Stealth
12
+ from rich.console import Console
13
+
14
+ from .utils import get_har_dir, get_timestamp
15
+ from .tui import THEME_PRIMARY, THEME_DIM, THEME_SUCCESS
16
+
17
+ console = Console()
18
+
19
+ # Realistic Chrome user agents (updated for late 2024/2025)
20
+ USER_AGENTS = [
21
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
22
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
23
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
24
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
25
+ ]
26
+
27
+ # Stealth JavaScript to inject - bypasses common detection methods
28
+ STEALTH_JS = """
29
+ // Override navigator.webdriver
30
+ Object.defineProperty(navigator, 'webdriver', {
31
+ get: () => undefined,
32
+ });
33
+
34
+ // Override navigator.plugins to look like a real browser
35
+ Object.defineProperty(navigator, 'plugins', {
36
+ get: () => {
37
+ const plugins = [
38
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
39
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
40
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
41
+ ];
42
+ plugins.item = (index) => plugins[index];
43
+ plugins.namedItem = (name) => plugins.find(p => p.name === name) || null;
44
+ plugins.refresh = () => {};
45
+ return plugins;
46
+ },
47
+ });
48
+
49
+ // Override navigator.languages
50
+ Object.defineProperty(navigator, 'languages', {
51
+ get: () => ['en-US', 'en'],
52
+ });
53
+
54
+ // Override navigator.permissions.query for notifications
55
+ const originalQuery = window.navigator.permissions.query;
56
+ window.navigator.permissions.query = (parameters) => {
57
+ if (parameters.name === 'notifications') {
58
+ return Promise.resolve({ state: Notification.permission });
59
+ }
60
+ return originalQuery(parameters);
61
+ };
62
+
63
+ // Remove automation-related properties from window
64
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
65
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_Object;
66
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
67
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_Proxy;
68
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;
69
+
70
+ // Override chrome runtime to look authentic
71
+ if (!window.chrome) {
72
+ window.chrome = {};
73
+ }
74
+ window.chrome.runtime = {
75
+ PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd' },
76
+ PlatformArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
77
+ PlatformNaclArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
78
+ RequestUpdateCheckStatus: { THROTTLED: 'throttled', NO_UPDATE: 'no_update', UPDATE_AVAILABLE: 'update_available' },
79
+ OnInstalledReason: { INSTALL: 'install', UPDATE: 'update', CHROME_UPDATE: 'chrome_update', SHARED_MODULE_UPDATE: 'shared_module_update' },
80
+ OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
81
+ };
82
+
83
+ // Fix iframe contentWindow detection
84
+ const originalAttachShadow = Element.prototype.attachShadow;
85
+ Element.prototype.attachShadow = function(init) {
86
+ if (init && init.mode === 'closed') {
87
+ init.mode = 'open';
88
+ }
89
+ return originalAttachShadow.call(this, init);
90
+ };
91
+
92
+ // Override WebGL vendor/renderer to look consistent
93
+ const getParameter = WebGLRenderingContext.prototype.getParameter;
94
+ WebGLRenderingContext.prototype.getParameter = function(parameter) {
95
+ if (parameter === 37445) { // UNMASKED_VENDOR_WEBGL
96
+ return 'Google Inc. (Apple)';
97
+ }
98
+ if (parameter === 37446) { // UNMASKED_RENDERER_WEBGL
99
+ return 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version)';
100
+ }
101
+ return getParameter.call(this, parameter);
102
+ };
103
+
104
+ // Do the same for WebGL2
105
+ const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
106
+ WebGL2RenderingContext.prototype.getParameter = function(parameter) {
107
+ if (parameter === 37445) {
108
+ return 'Google Inc. (Apple)';
109
+ }
110
+ if (parameter === 37446) {
111
+ return 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version)';
112
+ }
113
+ return getParameter2.call(this, parameter);
114
+ };
115
+
116
+ // Override Permissions API
117
+ const originalPermissionsQuery = navigator.permissions.query;
118
+ navigator.permissions.query = function(permissionDesc) {
119
+ if (permissionDesc.name === 'notifications') {
120
+ return Promise.resolve({
121
+ state: 'prompt',
122
+ onchange: null
123
+ });
124
+ }
125
+ return originalPermissionsQuery.call(navigator.permissions, permissionDesc);
126
+ };
127
+
128
+ // Spoof hardwareConcurrency to a realistic value
129
+ Object.defineProperty(navigator, 'hardwareConcurrency', {
130
+ get: () => 8,
131
+ });
132
+
133
+ // Spoof deviceMemory
134
+ Object.defineProperty(navigator, 'deviceMemory', {
135
+ get: () => 8,
136
+ });
137
+
138
+ // Spoof connection info
139
+ if (navigator.connection) {
140
+ Object.defineProperty(navigator.connection, 'rtt', {
141
+ get: () => 50,
142
+ });
143
+ }
144
+
145
+ // Hide automation in chrome.app
146
+ if (window.chrome && window.chrome.app) {
147
+ window.chrome.app.isInstalled = false;
148
+ window.chrome.app.InstallState = { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' };
149
+ window.chrome.app.RunningState = { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' };
150
+ }
151
+
152
+ console.log('Stealth mode activated');
153
+ """
154
+
155
+
156
+ # Default Chrome profile path on macOS
157
+ CHROME_USER_DATA_DIR = Path.home() / "Library/Application Support/Google/Chrome"
158
+
159
+
160
+ def get_chrome_profile_dir() -> Path | None:
161
+ """Get Chrome user data directory if it exists."""
162
+ if CHROME_USER_DATA_DIR.exists():
163
+ return CHROME_USER_DATA_DIR
164
+ return None
165
+
166
+
167
+ class ManualBrowser:
168
+ """Manages a Playwright browser session with HAR recording.
169
+
170
+ Supports two modes:
171
+ - Real Chrome: Uses your actual Chrome browser with existing profile (best for stealth)
172
+ - Stealth Chromium: Falls back to Playwright's Chromium with stealth patches
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ run_id: str,
178
+ prompt: str,
179
+ output_dir: str | None = None,
180
+ use_real_chrome: bool = True, # New option to use real Chrome
181
+ ):
182
+ self.run_id = run_id
183
+ self.prompt = prompt
184
+ self.output_dir = output_dir
185
+ self.use_real_chrome = use_real_chrome
186
+ self.har_dir = get_har_dir(run_id, output_dir)
187
+ self.har_path = self.har_dir / "recording.har"
188
+ self.metadata_path = self.har_dir / "metadata.json"
189
+ self._playwright = None
190
+ self._browser: Optional[Browser] = None
191
+ self._context: Optional[BrowserContext] = None
192
+ self._start_time: Optional[str] = None
193
+ self._user_agent = random.choice(USER_AGENTS)
194
+ self._using_persistent = False # Track if using persistent context
195
+
196
+ def _save_metadata(self, end_time: str) -> None:
197
+ """Save run metadata to JSON file."""
198
+ metadata = {
199
+ "run_id": self.run_id,
200
+ "prompt": self.prompt,
201
+ "start_time": self._start_time,
202
+ "end_time": end_time,
203
+ "har_file": str(self.har_path),
204
+ }
205
+ with open(self.metadata_path, "w") as f:
206
+ json.dump(metadata, f, indent=2)
207
+
208
+ def _handle_signal(self, signum, frame) -> None:
209
+ """Handle interrupt signals gracefully."""
210
+ console.print(f"\n\n [dim]terminating capture...[/dim]")
211
+ self.close()
212
+ sys.exit(0)
213
+
214
+ def _inject_stealth(self, page: Page) -> None:
215
+ """Inject stealth scripts into page before any other scripts run."""
216
+ page.add_init_script(STEALTH_JS)
217
+
218
+ def _start_with_real_chrome(self, start_url: Optional[str] = None) -> Path:
219
+ """Start using the real Chrome browser with user's profile."""
220
+ # We need to use a COPY of the profile to avoid locking issues
221
+ # Chrome locks its profile when running, so we can't use it directly
222
+ import shutil
223
+ import tempfile
224
+
225
+ chrome_profile = get_chrome_profile_dir()
226
+ if not chrome_profile:
227
+ console.print(f" [yellow]chrome profile not found, falling back to stealth mode[/yellow]")
228
+ return self._start_with_stealth_chromium(start_url)
229
+
230
+ # Create a temporary profile directory
231
+ temp_profile_dir = Path(tempfile.mkdtemp(prefix="chrome_profile_"))
232
+
233
+ console.print(f" [dim]using real chrome (profile copy)[/dim]")
234
+ console.print(f" [dim]note: close chrome if you have it open[/dim]")
235
+ console.print()
236
+
237
+ try:
238
+ # Use launch_persistent_context with channel="chrome" to use real Chrome binary
239
+ # This gives us the real Chrome with a fresh profile that has all extensions/settings
240
+ self._context = self._playwright.chromium.launch_persistent_context(
241
+ user_data_dir=str(temp_profile_dir),
242
+ channel="chrome", # Use real Chrome binary
243
+ headless=False,
244
+ record_har_path=str(self.har_path),
245
+ record_har_content="attach",
246
+ no_viewport=True,
247
+ args=[
248
+ "--start-maximized",
249
+ "--disable-blink-features=AutomationControlled",
250
+ ],
251
+ ignore_default_args=["--enable-automation", "--no-sandbox"],
252
+ )
253
+ self._using_persistent = True
254
+
255
+ # Get or create page
256
+ if self._context.pages:
257
+ page = self._context.pages[0]
258
+ else:
259
+ page = self._context.new_page()
260
+
261
+ if start_url:
262
+ page.goto(start_url, wait_until="domcontentloaded")
263
+
264
+ # Wait for browser to close
265
+ try:
266
+ while self._context.pages:
267
+ self._context.pages[0].wait_for_timeout(500)
268
+ except Exception:
269
+ pass
270
+
271
+ return self.close()
272
+
273
+ finally:
274
+ # Clean up temp profile
275
+ try:
276
+ shutil.rmtree(temp_profile_dir, ignore_errors=True)
277
+ except Exception:
278
+ pass
279
+
280
+ def _start_with_stealth_chromium(self, start_url: Optional[str] = None) -> Path:
281
+ """Start using Playwright's Chromium with stealth patches."""
282
+ # Comprehensive stealth Chrome arguments
283
+ chrome_args = [
284
+ "--start-maximized",
285
+ "--disable-blink-features=AutomationControlled",
286
+ "--disable-features=IsolateOrigins,site-per-process",
287
+ "--disable-infobars",
288
+ "--disable-dev-shm-usage",
289
+ "--disable-background-networking",
290
+ "--disable-default-apps",
291
+ "--disable-extensions",
292
+ "--disable-sync",
293
+ "--disable-translate",
294
+ "--no-first-run",
295
+ "--no-default-browser-check",
296
+ "--no-service-autorun",
297
+ "--disable-backgrounding-occluded-windows",
298
+ "--disable-renderer-backgrounding",
299
+ "--disable-background-timer-throttling",
300
+ "--disable-ipc-flooding-protection",
301
+ "--disable-hang-monitor",
302
+ "--disable-prompt-on-repost",
303
+ "--disable-client-side-phishing-detection",
304
+ "--disable-webrtc-hw-encoding",
305
+ "--disable-webrtc-hw-decoding",
306
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
307
+ "--enable-features=NetworkService,NetworkServiceInProcess",
308
+ "--disable-component-update",
309
+ "--disable-domain-reliability",
310
+ "--disable-features=AutofillServerCommunication",
311
+ "--password-store=basic",
312
+ "--use-mock-keychain",
313
+ ]
314
+
315
+ self._browser = self._playwright.chromium.launch(
316
+ headless=False,
317
+ args=chrome_args,
318
+ ignore_default_args=["--enable-automation", "--no-sandbox"],
319
+ )
320
+
321
+ # Create context with HAR recording and realistic settings
322
+ self._context = self._browser.new_context(
323
+ record_har_path=str(self.har_path),
324
+ record_har_content="attach",
325
+ no_viewport=True,
326
+ locale="en-US",
327
+ timezone_id="America/New_York",
328
+ user_agent=self._user_agent,
329
+ screen={"width": 1920, "height": 1080},
330
+ color_scheme="light",
331
+ reduced_motion="no-preference",
332
+ forced_colors="none",
333
+ extra_http_headers={
334
+ "Accept-Language": "en-US,en;q=0.9",
335
+ "sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
336
+ "sec-ch-ua-mobile": "?0",
337
+ "sec-ch-ua-platform": '"macOS"',
338
+ },
339
+ )
340
+
341
+ # Apply playwright-stealth evasions
342
+ stealth = Stealth()
343
+ stealth.apply_stealth_sync(self._context)
344
+
345
+ # Add custom stealth init script
346
+ self._context.add_init_script(STEALTH_JS)
347
+
348
+ # Open initial page
349
+ page = self._context.new_page()
350
+
351
+ if start_url:
352
+ page.goto(start_url, wait_until="domcontentloaded")
353
+
354
+ # Wait for browser to close
355
+ try:
356
+ while self._context.pages:
357
+ self._context.pages[0].wait_for_timeout(500)
358
+ except Exception:
359
+ pass
360
+
361
+ return self.close()
362
+
363
+ def start(self, start_url: Optional[str] = None) -> Path:
364
+ """Start the browser with HAR recording enabled. Returns HAR path when done."""
365
+ self._start_time = get_timestamp()
366
+
367
+ # Set up signal handlers for graceful shutdown
368
+ signal.signal(signal.SIGINT, self._handle_signal)
369
+ signal.signal(signal.SIGTERM, self._handle_signal)
370
+
371
+ console.print(f" [dim]capture starting...[/dim]")
372
+ console.print(f" [dim]━[/dim] [white]{self.run_id}[/white]")
373
+ console.print(f" [dim]goal[/dim] [white]{self.prompt}[/white]")
374
+ console.print()
375
+ console.print(f" [dim]navigate and interact to record traffic[/dim]")
376
+ console.print(f" [dim]close browser or ctrl+c to finalize[/dim]")
377
+ console.print()
378
+
379
+ self._playwright = sync_playwright().start()
380
+
381
+ # Try real Chrome first, fall back to stealth Chromium
382
+ if self.use_real_chrome:
383
+ return self._start_with_real_chrome(start_url)
384
+ else:
385
+ return self._start_with_stealth_chromium(start_url)
386
+
387
+ def close(self) -> Path:
388
+ """Close the browser and save HAR file. Returns HAR path."""
389
+ end_time = get_timestamp()
390
+
391
+ if self._context:
392
+ try:
393
+ self._context.close() # This saves the HAR file
394
+ except Exception:
395
+ pass
396
+ self._context = None
397
+
398
+ # Only close browser if not using persistent context
399
+ if self._browser and not self._using_persistent:
400
+ try:
401
+ self._browser.close()
402
+ except Exception:
403
+ pass
404
+ self._browser = None
405
+
406
+ if self._playwright:
407
+ try:
408
+ self._playwright.stop()
409
+ except Exception:
410
+ pass
411
+ self._playwright = None
412
+
413
+ # Save metadata
414
+ self._save_metadata(end_time)
415
+
416
+ console.print(f" [dim]capture saved[/dim]")
417
+ console.print(f" [dim]metadata synced[/dim]")
418
+
419
+ return self.har_path