refactor: modularize codebase and add 404 unit tests (#13)
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
This commit was merged in pull request #13.
This commit is contained in:
43
compliance-agent/src/pentest/report/archive.rs
Normal file
43
compliance-agent/src/pentest/report/archive.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::AesMode;
|
||||
|
||||
use super::ReportContext;
|
||||
|
||||
pub(super) fn build_zip(
|
||||
ctx: &ReportContext,
|
||||
password: &str,
|
||||
html: &str,
|
||||
pdf: &[u8],
|
||||
) -> Result<Vec<u8>, zip::result::ZipError> {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut zip = zip::ZipWriter::new(buf);
|
||||
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.with_aes_encryption(AesMode::Aes256, password);
|
||||
|
||||
// report.pdf (primary)
|
||||
zip.start_file("report.pdf", options)?;
|
||||
zip.write_all(pdf)?;
|
||||
|
||||
// report.html (fallback)
|
||||
zip.start_file("report.html", options)?;
|
||||
zip.write_all(html.as_bytes())?;
|
||||
|
||||
// findings.json
|
||||
let findings_json =
|
||||
serde_json::to_string_pretty(&ctx.findings).unwrap_or_else(|_| "[]".to_string());
|
||||
zip.start_file("findings.json", options)?;
|
||||
zip.write_all(findings_json.as_bytes())?;
|
||||
|
||||
// attack-chain.json
|
||||
let chain_json =
|
||||
serde_json::to_string_pretty(&ctx.attack_chain).unwrap_or_else(|_| "[]".to_string());
|
||||
zip.start_file("attack-chain.json", options)?;
|
||||
zip.write_all(chain_json.as_bytes())?;
|
||||
|
||||
let cursor = zip.finish()?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
1851
compliance-agent/src/pentest/report/html.rs
Normal file
1851
compliance-agent/src/pentest/report/html.rs
Normal file
File diff suppressed because it is too large
Load Diff
58
compliance-agent/src/pentest/report/mod.rs
Normal file
58
compliance-agent/src/pentest/report/mod.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
mod archive;
|
||||
mod html;
|
||||
mod pdf;
|
||||
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::pentest::{AttackChainNode, PentestSession};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Report archive with metadata
|
||||
pub struct ReportArchive {
|
||||
/// The password-protected ZIP bytes
|
||||
pub archive: Vec<u8>,
|
||||
/// SHA-256 hex digest of the archive
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Report context gathered from the database
|
||||
pub struct ReportContext {
|
||||
pub session: PentestSession,
|
||||
pub target_name: String,
|
||||
pub target_url: String,
|
||||
pub findings: Vec<DastFinding>,
|
||||
pub attack_chain: Vec<AttackChainNode>,
|
||||
pub requester_name: String,
|
||||
pub requester_email: String,
|
||||
}
|
||||
|
||||
/// Generate a password-protected ZIP archive containing the pentest report.
|
||||
///
|
||||
/// The archive contains:
|
||||
/// - `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 async fn generate_encrypted_report(
|
||||
ctx: &ReportContext,
|
||||
password: &str,
|
||||
) -> Result<ReportArchive, String> {
|
||||
let html = html::build_html_report(ctx);
|
||||
|
||||
// Convert HTML to PDF via headless Chrome
|
||||
let pdf_bytes = pdf::html_to_pdf(&html).await?;
|
||||
|
||||
let zip_bytes = archive::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);
|
||||
let sha256 = hex::encode(hasher.finalize());
|
||||
|
||||
Ok(ReportArchive {
|
||||
archive: zip_bytes,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
79
compliance-agent/src/pentest/report/pdf.rs
Normal file
79
compliance-agent/src/pentest/report/pdf.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
/// Convert HTML string to PDF bytes using headless Chrome/Chromium.
|
||||
pub(super) async fn html_to_pdf(html: &str) -> Result<Vec<u8>, 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<String> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user