feat: pentest feature improvements — streaming, pause/resume, encryption, browser tool, reports, docs
- True SSE streaming via broadcast channels (DashMap per session) - Session pause/resume with watch channels + dashboard buttons - AES-256-GCM credential encryption at rest (PENTEST_ENCRYPTION_KEY) - Concurrency limiter (Semaphore, max 5 sessions, 429 on overflow) - Browser tool: headless Chrome CDP automation (navigate, click, fill, screenshot, evaluate) - Report code-level correlation: SAST findings, code graph, SBOM linked per DAST finding - Split html.rs (1919 LOC) into html/ module directory (8 files) - Wizard: target/repo dropdowns from existing data, SSH key display, close button on all steps - Auth: auto-register with optional registration URL (Playwright discovery), plus-addressing email, IMAP overrides - Attack chain: tool input/output in detail panel, running node pulse animation - Architecture docs with Mermaid diagrams + 8 screenshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
488
compliance-dast/src/tools/browser.rs
Normal file
488
compliance-dast/src/tools/browser.rs
Normal file
@@ -0,0 +1,488 @@
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::info;
|
||||
|
||||
type WsStream =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
/// A browser automation tool that exposes headless Chrome actions to the LLM
|
||||
/// via the Chrome DevTools Protocol. Reuses the same `CHROME_WS_URL` used for
|
||||
/// PDF generation.
|
||||
///
|
||||
/// Supported actions: navigate, screenshot, click, fill, get_content, evaluate.
|
||||
pub struct BrowserTool;
|
||||
|
||||
impl Default for BrowserTool {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl PentestTool for BrowserTool {
|
||||
fn name(&self) -> &str {
|
||||
"browser"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Headless browser automation via Chrome DevTools Protocol. \
|
||||
Supports navigating to URLs, taking screenshots, clicking elements, \
|
||||
filling form fields, reading page content, and evaluating JavaScript. \
|
||||
Use CSS selectors to target elements. Useful for discovering registration pages, \
|
||||
filling out forms, extracting verification links, and visual inspection."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["navigate", "screenshot", "click", "fill", "get_content", "evaluate"],
|
||||
"description": "Action to perform"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to navigate to (for 'navigate' action)"
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector for click/fill actions"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Text value for 'fill' action, or JS expression for 'evaluate'"
|
||||
},
|
||||
"wait_ms": {
|
||||
"type": "integer",
|
||||
"description": "Milliseconds to wait after action (default: 500)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
_context: &'a PentestToolContext,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let action = input.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = input.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let selector = input.get("selector").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = input.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let wait_ms = input.get("wait_ms").and_then(|v| v.as_u64()).unwrap_or(500);
|
||||
|
||||
let mut session = BrowserSession::connect()
|
||||
.await
|
||||
.map_err(|e| CoreError::Other(format!("Browser connect failed: {e}")))?;
|
||||
|
||||
let result = match action {
|
||||
"navigate" => session.navigate(url, wait_ms).await,
|
||||
"screenshot" => session.screenshot().await,
|
||||
"click" => session.click(selector, wait_ms).await,
|
||||
"fill" => session.fill(selector, value, wait_ms).await,
|
||||
"get_content" => session.get_content().await,
|
||||
"evaluate" => session.evaluate(value).await,
|
||||
_ => Err(format!("Unknown browser action: {action}")),
|
||||
};
|
||||
|
||||
// Always try to clean up
|
||||
let _ = session.close().await;
|
||||
|
||||
match result {
|
||||
Ok(data) => {
|
||||
let summary = match action {
|
||||
"navigate" => format!("Navigated to {url}"),
|
||||
"screenshot" => "Captured page screenshot".to_string(),
|
||||
"click" => format!("Clicked element: {selector}"),
|
||||
"fill" => format!("Filled element: {selector}"),
|
||||
"get_content" => "Retrieved page content".to_string(),
|
||||
"evaluate" => "Evaluated JavaScript".to_string(),
|
||||
_ => "Browser action completed".to_string(),
|
||||
};
|
||||
info!(action, %summary, "Browser tool executed");
|
||||
Ok(PentestToolResult {
|
||||
summary,
|
||||
findings: Vec::new(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(PentestToolResult {
|
||||
summary: format!("Browser action '{action}' failed: {e}"),
|
||||
findings: Vec::new(),
|
||||
data: json!({ "error": e }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A single CDP session wrapping a browser tab.
|
||||
struct BrowserSession {
|
||||
ws: WsStream,
|
||||
next_id: u64,
|
||||
session_id: String,
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
impl BrowserSession {
|
||||
/// Connect to headless Chrome and create a new tab.
|
||||
async fn connect() -> Result<Self, String> {
|
||||
let ws_url = std::env::var("CHROME_WS_URL").map_err(|_| {
|
||||
"CHROME_WS_URL not set — headless Chrome is required for browser actions".to_string()
|
||||
})?;
|
||||
|
||||
// Discover browser WS endpoint
|
||||
let http_url = ws_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://");
|
||||
let version_url = format!("{http_url}/json/version");
|
||||
|
||||
let version: serde_json::Value = reqwest::get(&version_url)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot reach Chrome at {version_url}: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid /json/version response: {e}"))?;
|
||||
|
||||
let browser_ws = version["webSocketDebuggerUrl"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "No webSocketDebuggerUrl in /json/version".to_string())?;
|
||||
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(browser_ws)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connect failed: {e}"))?;
|
||||
|
||||
let mut next_id: u64 = 1;
|
||||
|
||||
// Create tab
|
||||
let resp = cdp_send(
|
||||
&mut ws,
|
||||
next_id,
|
||||
"Target.createTarget",
|
||||
json!({ "url": "about:blank" }),
|
||||
)
|
||||
.await?;
|
||||
next_id += 1;
|
||||
|
||||
let target_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("targetId"))
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or("No targetId in createTarget response")?
|
||||
.to_string();
|
||||
|
||||
// Attach
|
||||
let resp = cdp_send(
|
||||
&mut ws,
|
||||
next_id,
|
||||
"Target.attachToTarget",
|
||||
json!({ "targetId": target_id, "flatten": true }),
|
||||
)
|
||||
.await?;
|
||||
next_id += 1;
|
||||
|
||||
let session_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or("No sessionId in attachToTarget response")?
|
||||
.to_string();
|
||||
|
||||
// Enable domains
|
||||
cdp_send_session(&mut ws, next_id, &session_id, "Page.enable", json!({})).await?;
|
||||
next_id += 1;
|
||||
|
||||
cdp_send_session(&mut ws, next_id, &session_id, "Runtime.enable", json!({})).await?;
|
||||
next_id += 1;
|
||||
|
||||
Ok(Self {
|
||||
ws,
|
||||
next_id,
|
||||
session_id,
|
||||
target_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn navigate(&mut self, url: &str, wait_ms: u64) -> Result<serde_json::Value, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Page.navigate",
|
||||
json!({ "url": url }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
// Get page title
|
||||
let title_resp = self.evaluate_raw("document.title").await?;
|
||||
let page_url_resp = self.evaluate_raw("window.location.href").await?;
|
||||
|
||||
Ok(json!({
|
||||
"navigated": true,
|
||||
"url": page_url_resp,
|
||||
"title": title_resp,
|
||||
"frame_id": resp.get("result").and_then(|r| r.get("frameId")),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn screenshot(&mut self) -> Result<serde_json::Value, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Page.captureScreenshot",
|
||||
json!({ "format": "png", "quality": 80 }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let b64 = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("data"))
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let size_kb = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.map(|b| b.len() / 1024)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(json!({
|
||||
"screenshot_base64": b64,
|
||||
"size_kb": size_kb,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn click(&mut self, selector: &str, wait_ms: u64) -> Result<serde_json::Value, String> {
|
||||
// Use JS to find element and get its bounding box, then click
|
||||
let js = format!(
|
||||
r#"(function() {{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return JSON.stringify({{error: "Element not found: {raw}"}});
|
||||
var rect = el.getBoundingClientRect();
|
||||
el.click();
|
||||
return JSON.stringify({{
|
||||
clicked: true,
|
||||
tag: el.tagName,
|
||||
text: el.textContent.substring(0, 100),
|
||||
x: rect.x + rect.width/2,
|
||||
y: rect.y + rect.height/2
|
||||
}});
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
raw = selector.replace('"', r#"\""#),
|
||||
);
|
||||
|
||||
let result = self.evaluate_raw(&js).await?;
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(&result)
|
||||
.unwrap_or_else(|_| json!({ "result": result }));
|
||||
Ok(serde_json::from_str(&result).unwrap_or(json!({ "result": result })))
|
||||
}
|
||||
|
||||
async fn fill(
|
||||
&mut self,
|
||||
selector: &str,
|
||||
value: &str,
|
||||
wait_ms: u64,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let js = format!(
|
||||
r#"(function() {{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return JSON.stringify({{error: "Element not found: {raw}"}});
|
||||
el.focus();
|
||||
el.value = {val};
|
||||
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||
el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
||||
return JSON.stringify({{filled: true, tag: el.tagName, selector: {sel}}});
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
raw = selector.replace('"', r#"\""#),
|
||||
val = serde_json::to_string(value).unwrap_or_default(),
|
||||
);
|
||||
|
||||
let result = self.evaluate_raw(&js).await?;
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
Ok(serde_json::from_str(&result).unwrap_or(json!({ "result": result })))
|
||||
}
|
||||
|
||||
async fn get_content(&mut self) -> Result<serde_json::Value, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"DOM.getDocument",
|
||||
json!({ "depth": 0 }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let root_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("root"))
|
||||
.and_then(|n| n.get("nodeId"))
|
||||
.and_then(|n| n.as_i64())
|
||||
.unwrap_or(1);
|
||||
|
||||
let html_resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"DOM.getOuterHTML",
|
||||
json!({ "nodeId": root_id }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let html = html_resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("outerHTML"))
|
||||
.and_then(|h| h.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Also get page title and URL for context
|
||||
let title = self
|
||||
.evaluate_raw("document.title")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let url = self
|
||||
.evaluate_raw("window.location.href")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Truncate HTML to avoid massive payloads to the LLM
|
||||
let truncated = if html.len() > 50_000 {
|
||||
format!(
|
||||
"{}... [truncated, {} total chars]",
|
||||
&html[..50_000],
|
||||
html.len()
|
||||
)
|
||||
} else {
|
||||
html.to_string()
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"url": url,
|
||||
"title": title,
|
||||
"html": truncated,
|
||||
"html_length": html.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn evaluate(&mut self, expression: &str) -> Result<serde_json::Value, String> {
|
||||
let result = self.evaluate_raw(expression).await?;
|
||||
Ok(json!({
|
||||
"result": result,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Execute JS and return the string result.
|
||||
async fn evaluate_raw(&mut self, expression: &str) -> Result<String, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Runtime.evaluate",
|
||||
json!({
|
||||
"expression": expression,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let result = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("result"))
|
||||
.and_then(|r| r.get("value"));
|
||||
|
||||
match result {
|
||||
Some(serde_json::Value::String(s)) => Ok(s.clone()),
|
||||
Some(v) => Ok(v.to_string()),
|
||||
None => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), String> {
|
||||
let _ = cdp_send(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
"Target.closeTarget",
|
||||
json!({ "targetId": self.target_id }),
|
||||
)
|
||||
.await;
|
||||
let _ = self.ws.close(None).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDP helpers (same pattern as compliance-agent/src/pentest/report/pdf.rs) ──
|
||||
|
||||
async fn cdp_send(
|
||||
ws: &mut WsStream,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = json!({ "id": id, "method": method, "params": params });
|
||||
ws.send(Message::Text(msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("WS send failed: {e}"))?;
|
||||
read_until_result(ws, id).await
|
||||
}
|
||||
|
||||
async fn cdp_send_session(
|
||||
ws: &mut WsStream,
|
||||
id: u64,
|
||||
session_id: &str,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = json!({
|
||||
"id": id,
|
||||
"sessionId": session_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
ws.send(Message::Text(msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("WS send failed: {e}"))?;
|
||||
read_until_result(ws, id).await
|
||||
}
|
||||
|
||||
async fn read_until_result(ws: &mut WsStream, id: u64) -> Result<serde_json::Value, String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let msg = tokio::time::timeout_at(deadline, ws.next())
|
||||
.await
|
||||
.map_err(|_| format!("Timeout waiting for CDP response id={id}"))?
|
||||
.ok_or_else(|| "WebSocket closed unexpectedly".to_string())?
|
||||
.map_err(|e| format!("WebSocket read error: {e}"))?;
|
||||
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if val.get("id").and_then(|i| i.as_u64()) == Some(id) {
|
||||
if let Some(err) = val.get("error") {
|
||||
return Err(format!("CDP error: {err}"));
|
||||
}
|
||||
return Ok(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod api_fuzzer;
|
||||
pub mod auth_bypass;
|
||||
pub mod browser;
|
||||
pub mod console_log_detector;
|
||||
pub mod cookie_analyzer;
|
||||
pub mod cors_checker;
|
||||
@@ -114,6 +115,7 @@ impl ToolRegistry {
|
||||
Box::new(openapi_parser::OpenApiParserTool::new(http.clone())),
|
||||
);
|
||||
register(&mut tools, Box::new(recon::ReconTool::new(http)));
|
||||
register(&mut tools, Box::<browser::BrowserTool>::default());
|
||||
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user