/** * CDP Capture — Chrome DevTools Protocol session recorder * * Connects to a real Chrome instance via CDP and captures: * - All network requests (URL, method, headers, body, response) * - All console messages * - All user interactions (clicks, inputs, form submits) * - All page navigations * - Cookies & localStorage (on exit or on demand) * - DOM snapshots & screenshots (on demand or per-navigation) * * Usage: * node capture.js [options] * * Options: * --port= CDP port (default: 9222) * --host= CDP host (default: localhost) * --tab= Tab index to attach to (default: 0, use -1 for all tabs) * --screenshot Take screenshot on each navigation * --dom Save DOM snapshot on each navigation * --bodies Capture response bodies (default: off, can be heavy) * --filter= Only capture network requests matching this URL pattern * --outdir= Base output directory (default: ../../.. i.e. project root) * --no-interactions Disable interaction capture * --no-network Disable network capture * --no-console Disable console capture */ const CDP = require('chrome-remote-interface'); const fs = require('fs'); const path = require('path'); // ─── CLI Args ─────────────────────────────────────────────── function parseArgs() { const args = { port: 9222, host: 'localhost', tab: 0, outdir: null, screenshot: false, dom: false, bodies: false, filter: null, interactions: true, network: true, console: true }; for (const arg of process.argv.slice(2)) { const [key, val] = arg.startsWith('--') ? arg.slice(2).split('=') : [arg, true]; switch (key) { case 'port': args.port = parseInt(val); break; case 'host': args.host = val; break; case 'tab': args.tab = parseInt(val); break; case 'screenshot': args.screenshot = true; break; case 'dom': args.dom = true; break; case 'bodies': args.bodies = true; break; case 'filter': args.filter = val; break; case 'outdir': args.outdir = val; break; case 'no-interactions': args.interactions = false; break; case 'no-network': args.network = false; break; case 'no-console': args.console = false; break; } } return args; } // ─── Session Setup ────────────────────────────────────────── const args = parseArgs(); const now = new Date(); const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); const sessionName = `session_${ts}`; const projectRoot = args.outdir ? path.resolve(args.outdir) : path.resolve(__dirname, '..', '..', '..'); const dirs = { logs: path.join(projectRoot, 'logs', sessionName), scans: path.join(projectRoot, 'scans', 'host', sessionName), screenshots: path.join(projectRoot, 'evidence', 'screenshots', sessionName), loot: path.join(projectRoot, 'loot', 'credentials', sessionName), }; for (const d of Object.values(dirs)) fs.mkdirSync(d, { recursive: true }); // ─── Output Writers ───────────────────────────────────────── const writers = {}; function getWriter(name) { if (!writers[name]) { writers[name] = fs.createWriteStream(path.join(dirs.logs, `${name}.jsonl`), { flags: 'a' }); } return writers[name]; } function writeLine(name, obj) { const w = getWriter(name); w.write(JSON.stringify(obj) + '\n'); } // ─── Interaction Injection Script ─────────────────────────── const injectScript = ` (function() { if (window.__cdpCaptureInjected) return; window.__cdpCaptureInjected = true; function serialize(el) { if (!el) return null; return { tag: el.tagName?.toLowerCase(), id: el.id || undefined, class: el.className?.toString()?.substring(0, 300) || undefined, text: el.innerText?.substring(0, 300) || undefined, href: el.href || undefined, name: el.name || undefined, type: el.type || undefined, value: el.value?.substring(0, 1000) || undefined, action: el.action || undefined, method: el.method || undefined, xpath: (function() { try { const s = []; let n = el; while (n && n.nodeType === 1) { let idx = 1, sib = n.previousElementSibling; while (sib) { if (sib.tagName === n.tagName) idx++; sib = sib.previousElementSibling; } s.unshift(n.tagName.toLowerCase() + '[' + idx + ']'); n = n.parentElement; } return '/' + s.join('/'); } catch(e) { return undefined; } })() }; } // Capture clicks document.addEventListener('click', function(e) { __capture(JSON.stringify({ type: 'click', target: serialize(e.target), timestamp: Date.now(), url: location.href })); }, true); // Capture input changes document.addEventListener('input', function(e) { __capture(JSON.stringify({ type: 'input', target: serialize(e.target), timestamp: Date.now(), url: location.href })); }, true); // Capture form submissions document.addEventListener('submit', function(e) { var formData = {}; try { var fd = new FormData(e.target); fd.forEach(function(v, k) { formData[k] = (typeof v === 'string') ? v.substring(0, 1000) : '[file]'; }); } catch(err) {} __capture(JSON.stringify({ type: 'submit', target: serialize(e.target), formData: formData, timestamp: Date.now(), url: location.href })); }, true); // Capture keydown (for Enter, etc.) document.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === 'Tab') { __capture(JSON.stringify({ type: 'keydown', key: e.key, target: serialize(e.target), timestamp: Date.now(), url: location.href })); } }, true); })(); `; // ─── Tab Capture ──────────────────────────────────────────── const stats = { requests: 0, responses: 0, console: 0, interactions: 0, navigations: 0, screenshots: 0, domSnapshots: 0 }; const pendingRequests = new Map(); const filterRegex = args.filter ? new RegExp(args.filter) : null; async function attachToTarget(target, index) { const label = `[Tab ${index}: ${target.url?.substring(0, 80)}]`; console.log(`\n${label} Attaching...`); const client = await CDP({ target: target.id, host: args.host, port: args.port }); const { Network, Page, Runtime, Console, DOM, Storage } = client; // ── Enable domains ── await Network.enable(); await Page.enable(); await Runtime.enable(); await Console.enable(); if (args.dom) await DOM.enable(); // ── Add binding for interaction capture ── if (args.interactions) { await Runtime.addBinding({ name: '__capture' }); } // ── Network Events ── if (args.network) { Network.requestWillBeSent((params) => { const url = params.request.url; if (filterRegex && !filterRegex.test(url)) return; const entry = { requestId: params.requestId, url: url, method: params.request.method, headers: params.request.headers, postData: params.request.postData?.substring(0, 10000), type: params.type, initiator: params.initiator?.url, timestamp: params.timestamp, wallTime: params.wallTime, }; pendingRequests.set(params.requestId, entry); writeLine('network', { event: 'request', ...entry }); stats.requests++; }); Network.responseReceived((params) => { const url = params.response.url; if (filterRegex && !filterRegex.test(url)) return; const entry = { requestId: params.requestId, url: url, status: params.response.status, statusText: params.response.statusText, mimeType: params.response.mimeType, headers: params.response.headers, remoteIP: params.response.remoteIPAddress, remotePort: params.response.remotePort, protocol: params.response.protocol, timestamp: params.timestamp, }; writeLine('network', { event: 'response', ...entry }); stats.responses++; }); if (args.bodies) { Network.loadingFinished(async (params) => { try { const { body, base64Encoded } = await Network.getResponseBody({ requestId: params.requestId, }); const req = pendingRequests.get(params.requestId); if (req) { writeLine('network_bodies', { requestId: params.requestId, url: req.url, body: base64Encoded ? `[base64 ${body.length} chars]` : body.substring(0, 50000), base64Encoded, }); } } catch (e) { /* body not available */ } }); } } // ── Console Events ── if (args.console) { Runtime.consoleAPICalled((params) => { const args = params.args.map(a => a.value ?? a.description ?? a.unserializableValue ?? ''); writeLine('console', { type: params.type, args: args, stackTrace: params.stackTrace?.callFrames?.[0]?.functionName, timestamp: params.timestamp, url: target.url, }); stats.console++; }); Runtime.exceptionThrown((params) => { writeLine('console', { type: 'exception', text: params.exceptionDetails.text, exception: params.exceptionDetails.exception?.description?.substring(0, 2000), url: params.exceptionDetails.url, lineNumber: params.exceptionDetails.lineNumber, timestamp: Date.now() / 1000, }); }); } // ── Interaction Events (via binding) ── if (args.interactions) { Runtime.bindingCalled((params) => { if (params.name === '__capture') { try { const data = JSON.parse(params.payload); writeLine('interactions', data); stats.interactions++; } catch (e) { /* malformed */ } } }); } // ── Navigation Events ── Page.frameNavigated(async (params) => { if (params.frame.parentId) return; // only top-level const url = params.frame.url; writeLine('navigations', { url: url, name: params.frame.name, timestamp: Date.now() / 1000, }); stats.navigations++; console.log(`${label} Navigation → ${url.substring(0, 100)}`); // Re-inject interaction listeners after navigation if (args.interactions) { try { await Runtime.evaluate({ expression: injectScript, returnByValue: false }); } catch (e) { /* page not ready */ } } // Screenshot on navigation if (args.screenshot) { try { await takeScreenshot(client, index, url); } catch (e) { /* timing */ } } // DOM snapshot on navigation if (args.dom) { try { await saveDOMSnapshot(client, index, url); } catch (e) { /* timing */ } } }); // ── Inject listeners on load ── Page.loadEventFired(async () => { if (args.interactions) { try { await Runtime.evaluate({ expression: injectScript, returnByValue: false }); console.log(`${label} Interaction listeners injected.`); } catch (e) { /* page not ready */ } } }); // Initial injection (in case page is already loaded) if (args.interactions) { try { await Runtime.evaluate({ expression: injectScript, returnByValue: false }); } catch (e) { /* page not ready */ } } console.log(`${label} Capturing. (network=${args.network}, console=${args.console}, interactions=${args.interactions})`); return client; } // ─── Screenshot ────────────────────────────────────────────── async function takeScreenshot(client, tabIndex, url) { const { Page } = client; const { data } = await Page.captureScreenshot({ format: 'png' }); const fname = `tab${tabIndex}_${Date.now()}.png`; fs.writeFileSync(path.join(dirs.screenshots, fname), Buffer.from(data, 'base64')); writeLine('screenshots', { file: fname, url, timestamp: Date.now() / 1000 }); stats.screenshots++; console.log(` 📸 Screenshot: ${fname}`); } // ─── DOM Snapshot ──────────────────────────────────────────── async function saveDOMSnapshot(client, tabIndex, url) { const { DOM, Page } = client; const { root } = await DOM.getDocument({ depth: -1 }); const html = await DOM.getOuterHTML({ nodeId: root.nodeId }); const fname = `tab${tabIndex}_${Date.now()}.html`; fs.writeFileSync(path.join(dirs.scans, fname), html.outerHTML || String(html)); stats.domSnapshots++; console.log(` 📄 DOM snapshot: ${fname}`); } // ─── Cookie & Storage Extraction ───────────────────────────── async function extractCookies(clients) { console.log('\nExtracting cookies & storage...'); for (let i = 0; i < clients.length; i++) { const client = clients[i]; if (!client) continue; try { const { Network, Runtime, Storage } = client; // Cookies via Network domain const { cookies } = await Network.getCookies({}); fs.writeFileSync( path.join(dirs.loot, `cookies_tab${i}.json`), JSON.stringify(cookies, null, 2) ); // localStorage const lsResult = await Runtime.evaluate({ expression: `JSON.stringify(Object.fromEntries(Object.entries(localStorage)))`, returnByValue: true, }); if (lsResult.result?.value) { fs.writeFileSync( path.join(dirs.loot, `localStorage_tab${i}.json`), lsResult.result.value ); } // sessionStorage const ssResult = await Runtime.evaluate({ expression: `JSON.stringify(Object.fromEntries(Object.entries(sessionStorage)))`, returnByValue: true, }); if (ssResult.result?.value) { fs.writeFileSync( path.join(dirs.loot, `sessionStorage_tab${i}.json`), ssResult.result.value ); } console.log(` Tab ${i}: ${cookies.length} cookies, localStorage + sessionStorage saved`); } catch (e) { console.log(` Tab ${i}: extraction failed (${e.message})`); } } } // ─── Main ──────────────────────────────────────────────────── async function main() { console.log('╔══════════════════════════════════════════════════╗'); console.log('║ CDP Capture — Session Recorder ║'); console.log('╚══════════════════════════════════════════════════╝'); console.log(`\nSession: ${sessionName}`); console.log(`Output: ${projectRoot}`); console.log(`CDP: ${args.host}:${args.port}`); // Get browser targets let targets; try { targets = await CDP.List({ host: args.host, port: args.port }); } catch (e) { console.error(`\n❌ Cannot connect to CDP at ${args.host}:${args.port}`); console.error(` Make sure Chrome is running with --remote-debugging-port=${args.port}`); console.error(` Use: tools/scripts/launch-chrome.ps1`); process.exit(1); } const pageTargets = targets.filter(t => t.type === 'page'); if (pageTargets.length === 0) { console.error('\n❌ No browser tabs found. Open a tab in Chrome first.'); process.exit(1); } console.log(`\nFound ${pageTargets.length} tab(s):`); pageTargets.forEach((t, i) => { console.log(` [${i}] ${t.url?.substring(0, 100)}`); }); // Select tabs to attach let tabsToAttach; if (args.tab === -1) { tabsToAttach = pageTargets.map((t, i) => ({ target: t, index: i })); } else if (args.tab >= 0 && args.tab < pageTargets.length) { tabsToAttach = [{ target: pageTargets[args.tab], index: args.tab }]; } else { console.error(`\n❌ Invalid tab index: ${args.tab}`); process.exit(1); } // Attach to targets const clients = []; for (const { target, index } of tabsToAttach) { try { const client = await attachToTarget(target, index); clients.push(client); } catch (e) { console.error(`Tab ${index}: attach failed — ${e.message}`); clients.push(null); } } console.log(`\n✅ Capturing ${clients.filter(Boolean).length} tab(s). Press Ctrl+C to stop.\n`); // ── Graceful Shutdown ── let shuttingDown = false; process.on('SIGINT', async () => { if (shuttingDown) return; shuttingDown = true; console.log('\n\nShutting down...'); // Extract cookies & storage await extractCookies(clients); // Write summary const summary = { session: sessionName, startedAt: now.toISOString(), endedAt: new Date().toISOString(), args: args, stats: stats, outputDirs: dirs, }; fs.writeFileSync(path.join(dirs.logs, 'summary.json'), JSON.stringify(summary, null, 2)); console.log(`\n📊 Session Summary:`); console.log(` Network requests: ${stats.requests}`); console.log(` Network responses: ${stats.responses}`); console.log(` Console messages: ${stats.console}`); console.log(` Interactions: ${stats.interactions}`); console.log(` Navigations: ${stats.navigations}`); console.log(` Screenshots: ${stats.screenshots}`); console.log(` DOM snapshots: ${stats.domSnapshots}`); console.log(`\n📁 Output: ${dirs.logs}`); // Close writers for (const w of Object.values(writers)) w.end(); // Close clients for (const c of clients) { if (c) try { await c.close(); } catch (e) {} } process.exit(0); }); } main().catch(e => { console.error('Fatal error:', e); process.exit(1); });