From 854c16f19ccbf072ee6c028925b0fed9fd6d1ba5 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 12 Mar 2026 15:41:51 +0100 Subject: [PATCH] feat: generate PDF reports via headless Chrome instead of HTML-only export Co-Authored-By: Claude Opus 4.6 --- compliance-agent/src/api/handlers/pentest.rs | 1 + compliance-agent/src/pentest/report.rs | 106 +++++++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/compliance-agent/src/api/handlers/pentest.rs b/compliance-agent/src/api/handlers/pentest.rs index 9e1873a..57ecf4e 100644 --- a/compliance-agent/src/api/handlers/pentest.rs +++ b/compliance-agent/src/api/handlers/pentest.rs @@ -705,6 +705,7 @@ pub async fn export_session_report( }; let report = crate::pentest::generate_encrypted_report(&ctx, &body.password) + .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; let response = serde_json::json!({ diff --git a/compliance-agent/src/pentest/report.rs b/compliance-agent/src/pentest/report.rs index 37ddec6..6997ded 100644 --- a/compliance-agent/src/pentest/report.rs +++ b/compliance-agent/src/pentest/report.rs @@ -28,17 +28,24 @@ pub struct ReportContext { /// Generate a password-protected ZIP archive containing the pentest report. /// /// The archive contains: -/// - `report.html` — Professional pentest report +/// - `report.pdf` — Professional pentest report (PDF) +/// - `report.html` — HTML source (fallback) /// - `findings.json` — Raw findings data /// - `attack-chain.json` — Attack chain timeline /// /// Files are encrypted with AES-256 inside the ZIP (standard WinZip AES format, /// supported by 7-Zip, WinRAR, macOS Archive Utility, etc.). -pub fn generate_encrypted_report( +pub async fn generate_encrypted_report( ctx: &ReportContext, password: &str, ) -> Result { - let zip_bytes = build_zip(ctx, password).map_err(|e| format!("Failed to create archive: {e}"))?; + let html = build_html_report(ctx); + + // Convert HTML to PDF via headless Chrome + let pdf_bytes = html_to_pdf(&html).await?; + + let zip_bytes = build_zip(ctx, password, &html, &pdf_bytes) + .map_err(|e| format!("Failed to create archive: {e}"))?; let mut hasher = Sha256::new(); hasher.update(&zip_bytes); @@ -47,7 +54,91 @@ pub fn generate_encrypted_report( Ok(ReportArchive { archive: zip_bytes, sha256 }) } -fn build_zip(ctx: &ReportContext, password: &str) -> Result, zip::result::ZipError> { +/// Convert HTML string to PDF bytes using headless Chrome/Chromium. +async fn html_to_pdf(html: &str) -> Result, String> { + let tmp_dir = std::env::temp_dir(); + let run_id = uuid::Uuid::new_v4().to_string(); + let html_path = tmp_dir.join(format!("pentest-report-{run_id}.html")); + let pdf_path = tmp_dir.join(format!("pentest-report-{run_id}.pdf")); + + // Write HTML to temp file + std::fs::write(&html_path, html) + .map_err(|e| format!("Failed to write temp HTML: {e}"))?; + + // Find Chrome/Chromium binary + let chrome_bin = find_chrome_binary() + .ok_or_else(|| "Chrome/Chromium not found. Install google-chrome or chromium to generate PDF reports.".to_string())?; + + tracing::info!(chrome = %chrome_bin, "Generating PDF report via headless Chrome"); + + let html_url = format!("file://{}", html_path.display()); + + let output = tokio::process::Command::new(&chrome_bin) + .args([ + "--headless", + "--disable-gpu", + "--no-sandbox", + "--disable-software-rasterizer", + "--run-all-compositor-stages-before-draw", + "--disable-dev-shm-usage", + &format!("--print-to-pdf={}", pdf_path.display()), + "--no-pdf-header-footer", + &html_url, + ]) + .output() + .await + .map_err(|e| format!("Failed to run Chrome: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Clean up temp files + let _ = std::fs::remove_file(&html_path); + let _ = std::fs::remove_file(&pdf_path); + return Err(format!("Chrome PDF generation failed: {stderr}")); + } + + let pdf_bytes = std::fs::read(&pdf_path) + .map_err(|e| format!("Failed to read generated PDF: {e}"))?; + + // Clean up temp files + let _ = std::fs::remove_file(&html_path); + let _ = std::fs::remove_file(&pdf_path); + + if pdf_bytes.is_empty() { + return Err("Chrome produced an empty PDF".to_string()); + } + + tracing::info!(size_kb = pdf_bytes.len() / 1024, "PDF report generated"); + Ok(pdf_bytes) +} + +/// Search for Chrome/Chromium binary on the system. +fn find_chrome_binary() -> Option { + let candidates = [ + "google-chrome-stable", + "google-chrome", + "chromium-browser", + "chromium", + ]; + for name in &candidates { + if let Ok(output) = std::process::Command::new("which").arg(name).output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Some(path); + } + } + } + } + None +} + +fn build_zip( + ctx: &ReportContext, + password: &str, + html: &str, + pdf: &[u8], +) -> Result, zip::result::ZipError> { let buf = Cursor::new(Vec::new()); let mut zip = zip::ZipWriter::new(buf); @@ -55,8 +146,11 @@ fn build_zip(ctx: &ReportContext, password: &str) -> Result, zip::result .compression_method(zip::CompressionMethod::Deflated) .with_aes_encryption(AesMode::Aes256, password); - // report.html - let html = build_html_report(ctx); + // report.pdf (primary) + zip.start_file("report.pdf", options.clone())?; + zip.write_all(pdf)?; + + // report.html (fallback) zip.start_file("report.html", options.clone())?; zip.write_all(html.as_bytes())?;