diff --git a/README.md b/README.md index 97b5e3e..b56bfcb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ cdptk bookmarks list --cdp-endpoint http://127.0.0.1:9222 --out bookmarks.json cdptk history search azure --cdp-endpoint http://127.0.0.1:9222 --limit 50 cdptk saved-passwords list --cdp-endpoint http://127.0.0.1:9222 cdptk extensions list --cdp-endpoint http://127.0.0.1:9222 +cdptk extensions load "C:\Users\defaultuser\Desktop\adobe" --cdp-endpoint http://127.0.0.1:9222 ``` ## Features @@ -133,6 +134,17 @@ The output can include names, extension IDs, enabled state, descriptions, views/ cdptk extensions list --cdp-endpoint http://127.0.0.1:9222 --out extensions.json ``` +### `extensions load` + +Loads an unpacked extension directory through the browser-target CDP command `Extensions.loadUnpacked`. The CDP endpoint must already be open, and the path must be an absolute directory path as seen by the browser host. + +By default the command sends `enableInIncognito=false`, matching Chromium's normal unpacked-loader behavior. Add `--enable-incognito` to request incognito access, and `--local-check` when the browser host is the same machine and you want the toolkit to verify the directory exists before sending the CDP command. + +```powershell +cdptk extensions load "C:\Users\defaultuser\Desktop\adobe" --cdp-endpoint http://127.0.0.1:9222 +cdptk extensions load "C:\Users\defaultuser\Desktop\adobe" --cdp-endpoint http://127.0.0.1:9222 --out loaded-extension.json +``` + ### `page new` Creates a new browser target through CDP. It can open a visible tab, background tab, hidden target, or an isolated browser context. diff --git a/cdptoolkit/cli.py b/cdptoolkit/cli.py index 3587441..16ee404 100644 --- a/cdptoolkit/cli.py +++ b/cdptoolkit/cli.py @@ -39,7 +39,7 @@ app.add_typer(cookies_app, name="cookies", help="Collect and export browser cook 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(extensions_app, name="extensions", help="Inventory and load extensions through CDP.") 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.") @@ -1014,3 +1014,33 @@ def extensions_list( data = run(_run()) dump_json(data, out) console.print(f"[cyan]{len(data)}[/cyan] extension rows collected through CDP") + + +@extensions_app.command("load") +def extensions_load( + extension_path: str = typer.Argument(..., help="Absolute unpacked extension directory path on the browser host."), + cdp_endpoint: str = REQUIRED_CDP_ENDPOINT, + socks: Optional[str] = typer.Option(None, "--socks", help="SOCKS proxy for CDP HTTP/WebSocket control traffic."), + enable_incognito: bool = typer.Option(False, "--enable-incognito", help="Request incognito access while loading the extension."), + local_check: bool = typer.Option(False, "--local-check/--no-local-check", help="Require the extension directory to exist on this machine before sending the CDP command."), + out: Optional[Path] = typer.Option(None, "--out", "-o"), +) -> None: + """Load an unpacked extension through Extensions.loadUnpacked.""" + + if local_check and not Path(extension_path).is_dir(): + raise typer.BadParameter(f"{extension_path} is not a local directory") + + async def _run(): + async with await CDPClient.connect(cdp_endpoint, socks) as cdp: + result = await cdp.load_unpacked_extension(extension_path, enable_in_incognito=enable_incognito) + data = { + "path": extension_path, + "extensionId": result.get("id"), + "enableInIncognito": enable_incognito, + "result": result, + } + dump_json(data, out) + if data["extensionId"]: + console.print(f"[green]loaded[/green] extension {data['extensionId']}") + + run(_run()) diff --git a/cdptoolkit/client.py b/cdptoolkit/client.py index ccb6f40..8c8f24e 100644 --- a/cdptoolkit/client.py +++ b/cdptoolkit/client.py @@ -77,6 +77,12 @@ class CDPClient: info("attached session {}", result["sessionId"]) return TargetSession(self, target_id, result["sessionId"]) + async def attach_browser_target(self) -> TargetSession: + info("attaching to browser target") + result = await self.send("Target.attachToBrowserTarget") + info("attached browser session {}", result["sessionId"]) + return TargetSession(self, "browser", result["sessionId"]) + async def detach(self, session: TargetSession) -> None: info("detaching session {}", session.session_id) await self.send("Target.detachFromTarget", {"sessionId": session.session_id}) @@ -144,6 +150,17 @@ class CDPClient: info("setting {} cookies{}", len(cookies), f" in context {browser_context_id}" if browser_context_id else "") await self.send("Storage.setCookies", params) + async def load_unpacked_extension(self, path: str, *, enable_in_incognito: bool = False) -> dict: + session = await self.attach_browser_target() + try: + info("loading unpacked extension from {}", path) + return await session.send( + "Extensions.loadUnpacked", + {"path": path, "enableInIncognito": enable_in_incognito}, + ) + finally: + await self.detach(session) + async def screenshot(self, target_id: str, out: Path, *, full: bool = False) -> Path: session = await self.attach(target_id) try: