use compliance_core::error::CoreError; use compliance_core::models::dast::{DastEvidence, DastFinding, DastVulnType}; use compliance_core::models::Severity; use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult}; use serde_json::json; use tracing::info; /// Tool that detects console.log and similar debug statements in frontend JavaScript. pub struct ConsoleLogDetectorTool { http: reqwest::Client, } /// A detected console statement with its context. #[derive(Debug)] struct ConsoleMatch { pattern: String, file_url: String, line_snippet: String, line_number: Option, } impl ConsoleLogDetectorTool { pub fn new(http: reqwest::Client) -> Self { Self { http } } /// Patterns that indicate debug/logging statements left in production code. fn patterns() -> Vec<&'static str> { vec![ "console.log(", "console.debug(", "console.error(", "console.warn(", "console.info(", "console.trace(", "console.dir(", "console.table(", "debugger;", "alert(", ] } /// Extract JavaScript file URLs from an HTML page body. fn extract_js_urls(html: &str, base_url: &str) -> Vec { let mut urls = Vec::new(); let base = url::Url::parse(base_url).ok(); // Simple regex-free extraction of "#; let urls = ConsoleLogDetectorTool::extract_js_urls(html, "https://example.com"); assert_eq!(urls.len(), 3); assert!(urls.contains(&"https://example.com/static/app.js".to_string())); assert!(urls.contains(&"https://cdn.example.com/lib.js".to_string())); assert!(urls.contains(&"https://cdn2.example.com/vendor.js".to_string())); } #[test] fn extract_js_urls_no_scripts() { let html = "

Hello

"; let urls = ConsoleLogDetectorTool::extract_js_urls(html, "https://example.com"); assert!(urls.is_empty()); } #[test] fn extract_js_urls_filters_non_js() { let html = r#""#; let urls = ConsoleLogDetectorTool::extract_js_urls(html, "https://example.com"); // Only .js files should be extracted assert_eq!(urls.len(), 1); assert!(urls[0].ends_with("/app.js")); } #[test] fn scan_js_content_finds_console_log() { let js = r#" function init() { console.log("debug info"); doStuff(); } "#; let matches = ConsoleLogDetectorTool::scan_js_content(js, "https://example.com/app.js"); assert_eq!(matches.len(), 1); assert_eq!(matches[0].pattern, "console.log"); assert_eq!(matches[0].line_number, Some(3)); } #[test] fn scan_js_content_finds_multiple_patterns() { let js = "console.log('a');\nconsole.debug('b');\nconsole.error('c');\ndebugger;\nalert('x');"; let matches = ConsoleLogDetectorTool::scan_js_content(js, "test.js"); assert_eq!(matches.len(), 5); } #[test] fn scan_js_content_skips_comments() { let js = "// console.log('commented out');\n* console.log('also comment');\n/* console.log('block comment') */"; let matches = ConsoleLogDetectorTool::scan_js_content(js, "test.js"); assert!(matches.is_empty()); } #[test] fn scan_js_content_one_match_per_line() { let js = "console.log('a'); console.debug('b');"; let matches = ConsoleLogDetectorTool::scan_js_content(js, "test.js"); // Only one match per line assert_eq!(matches.len(), 1); } #[test] fn scan_js_content_empty_input() { let matches = ConsoleLogDetectorTool::scan_js_content("", "test.js"); assert!(matches.is_empty()); } #[test] fn patterns_list_is_not_empty() { let patterns = ConsoleLogDetectorTool::patterns(); assert!(patterns.len() >= 8); assert!(patterns.contains(&"console.log(")); assert!(patterns.contains(&"debugger;")); } } impl PentestTool for ConsoleLogDetectorTool { fn name(&self) -> &str { "console_log_detector" } fn description(&self) -> &str { "Detects console.log, console.debug, console.error, debugger, and similar debug \ statements left in production JavaScript. Fetches the HTML page and referenced JS files." } fn input_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "url": { "type": "string", "description": "URL of the page to check for console.log leakage" }, "additional_js_urls": { "type": "array", "description": "Optional additional JavaScript file URLs to scan", "items": { "type": "string" } } }, "required": ["url"] }) } fn execute<'a>( &'a self, input: serde_json::Value, context: &'a PentestToolContext, ) -> std::pin::Pin< Box> + Send + 'a>, > { Box::pin(async move { let url = input .get("url") .and_then(|v| v.as_str()) .ok_or_else(|| CoreError::Dast("Missing required 'url' parameter".to_string()))?; let additional_js: Vec = input .get("additional_js_urls") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }) .unwrap_or_default(); let target_id = context .target .id .map(|oid| oid.to_hex()) .unwrap_or_else(|| "unknown".to_string()); // Fetch the main page let response = self .http .get(url) .send() .await .map_err(|e| CoreError::Dast(format!("Failed to fetch {url}: {e}")))?; let html = response.text().await.unwrap_or_default(); // Scan inline scripts in the HTML let mut all_matches = Vec::new(); let inline_matches = Self::scan_js_content(&html, url); all_matches.extend(inline_matches); // Extract JS file URLs from the HTML let mut js_urls = Self::extract_js_urls(&html, url); js_urls.extend(additional_js); js_urls.dedup(); // Fetch and scan each JS file for js_url in &js_urls { match self.http.get(js_url).send().await { Ok(resp) => { if resp.status().is_success() { let js_content = resp.text().await.unwrap_or_default(); // Only scan non-minified-looking files or files where we can still // find patterns (minifiers typically strip console calls, but not always) let file_matches = Self::scan_js_content(&js_content, js_url); all_matches.extend(file_matches); } } Err(_) => continue, } } let mut findings = Vec::new(); let match_data: Vec = all_matches .iter() .map(|m| { json!({ "pattern": m.pattern, "file": m.file_url, "line": m.line_number, "snippet": m.line_snippet, }) }) .collect(); if !all_matches.is_empty() { // Group by file for the finding let mut by_file: std::collections::HashMap<&str, Vec<&ConsoleMatch>> = std::collections::HashMap::new(); for m in &all_matches { by_file.entry(&m.file_url).or_default().push(m); } for (file_url, matches) in &by_file { let pattern_summary: Vec = matches .iter() .take(5) .map(|m| { format!( " Line {}: {} - {}", m.line_number.unwrap_or(0), m.pattern, if m.line_snippet.len() > 80 { format!("{}...", &m.line_snippet[..80]) } else { m.line_snippet.clone() } ) }) .collect(); let evidence = DastEvidence { request_method: "GET".to_string(), request_url: file_url.to_string(), request_headers: None, request_body: None, response_status: 200, response_headers: None, response_snippet: Some(pattern_summary.join("\n")), screenshot_path: None, payload: None, response_time_ms: None, }; let total = matches.len(); let extra = if total > 5 { format!(" (and {} more)", total - 5) } else { String::new() }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::ConsoleLogLeakage, format!("Console/debug statements in {}", file_url), format!( "Found {total} console/debug statements in {file_url}{extra}. \ These can leak sensitive information such as API responses, user data, \ or internal state to anyone with browser developer tools open." ), Severity::Low, file_url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-532".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Remove console.log/debug/error statements from production code. \ Use a build step (e.g., babel plugin, terser) to strip console calls \ during the production build." .to_string(), ); findings.push(finding); } } let total_matches = all_matches.len(); let count = findings.len(); info!( url, js_files = js_urls.len(), total_matches, "Console log detection complete" ); Ok(PentestToolResult { summary: if total_matches > 0 { format!( "Found {total_matches} console/debug statements across {} files.", count ) } else { format!( "No console/debug statements found in HTML or {} JS files.", js_urls.len() ) }, findings, data: json!({ "total_matches": total_matches, "js_files_scanned": js_urls.len(), "matches": match_data, }), }) }) } }