use super::viz::generate_entropy_heatmap;
use crate::core::{AnalysisResult, Confidence, RbatError, Result, RiskAssessment};
use askama::Template;
use chrono::Local;
use std::fs;
use std::path::Path;
#[derive(Template)]
#[template(path = "report.html")]
struct ReportTemplate {
target_file: String,
analysis_date: String,
score: u32,
severity: String,
severity_class: String,
recommendations: Vec<String>,
has_heatmap: bool,
heatmap_svg: String,
findings: Vec<FindingContext>,
binary_type: String,
entry_point: String,
architecture: String,
capabilities: Vec<TechnicalFinding>,
signatures: Vec<TechnicalFinding>,
}
struct TechnicalFinding {
category: String,
details: String,
}
struct FindingContext {
indicator: String,
confidence: String,
confidence_class: String,
description: String,
}
const REPORT_CSS: &str = include_str!("../../templates/report.css");
pub fn generate_pdf_report(
filename: &Path,
assessment: &RiskAssessment,
analysis_result: &AnalysisResult,
out_path: &Path,
) -> Result<()> {
let heatmap_svg_content = generate_entropy_heatmap(&analysis_result.section_entropy);
let has_heatmap = !heatmap_svg_content.trim().is_empty();
let severity_class = match assessment.severity.to_lowercase().as_str() {
"malicious" => "malicious",
"suspicious" => "suspicious",
"safe" => "safe",
_ => "safe",
};
let findings: Vec<FindingContext> = assessment
.findings
.iter()
.map(|f| {
let conf_str = format!("{:?}", f.confidence);
let confidence_class = match f.confidence {
Confidence::Critical => "critical",
Confidence::High => "high",
Confidence::Medium => "medium",
Confidence::Low => "low",
};
FindingContext {
indicator: f.indicator.clone(),
confidence: conf_str.to_uppercase(),
confidence_class: confidence_class.to_string(),
description: f.description.clone(),
}
})
.collect();
let mut capabilities = Vec::new();
for (api, count) in &analysis_result.api_hooking {
capabilities.push(TechnicalFinding {
category: "API Hooking".to_string(),
details: format!("{} st_value {}", api, count),
});
}
for func in &analysis_result.process_injection {
capabilities.push(TechnicalFinding {
category: "Process Injection".to_string(),
details: format!("Suspicious function: {}", func),
});
}
for (section, caves) in &analysis_result.code_cave {
capabilities.push(TechnicalFinding {
category: "Code Cave".to_string(),
details: format!("Found {} caves in section {}", caves.len(), section),
});
}
for (mnemonic, addresses) in &analysis_result.blacklisted_mnemonics {
let sample = if addresses.is_empty() {
"".to_string()
} else {
let limit = std::cmp::min(addresses.len(), 3);
let formatted_addrs: Vec<String> = addresses[..limit]
.iter()
.map(|addr| format!("0x{:X}", addr))
.collect();
format!(" (at {})", formatted_addrs.join(", "))
};
capabilities.push(TechnicalFinding {
category: "Suspicious Instructions".to_string(),
details: format!(
"Instruction '{}' used {} times{}",
mnemonic,
addresses.len(),
sample
),
});
}
let mut signatures = Vec::new();
for (rule, matches) in &analysis_result.packer_signatures {
for m in matches {
signatures.push(TechnicalFinding {
category: "Packer/Protector".to_string(),
details: format!(
"{} matched in section {} at 0x{:X}",
rule, m.section, m.offset
),
});
}
}
for (rule, matches) in &analysis_result.string_values {
for m in matches {
signatures.push(TechnicalFinding {
category: "YARA Rule Match".to_string(),
details: format!(
"Rule {} matched '{}' in {}",
rule,
m.data.chars().take(30).collect::<String>(),
m.section
),
});
}
}
let template = ReportTemplate {
target_file: filename
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
analysis_date: Local::now().format("%Y-%m-%d").to_string(),
score: assessment.score,
severity: assessment.severity.clone().to_uppercase(),
severity_class: severity_class.to_string(),
recommendations: assessment.recommendations.clone(),
has_heatmap,
heatmap_svg: heatmap_svg_content,
findings,
binary_type: analysis_result.metadata.binary_type.clone(),
entry_point: format!("0x{:X}", analysis_result.metadata.entry_point),
architecture: analysis_result.metadata.architecture.to_string(),
capabilities,
signatures,
};
let html = template
.render()
.map_err(|e| RbatError::TemplateError(e.to_string()))?;
match generate_pdf_from_html(&html, out_path) {
Ok(()) => {
println!("[+] PDF report generated at {}", out_path.display());
Ok(())
}
Err(e) => {
let mut html_path = out_path.to_path_buf();
html_path.set_extension("html");
fs::write(&html_path, &html)?;
eprintln!(
"[-] PDF generation failed ({}). \
Report saved as HTML: {}",
e,
html_path.display()
);
Ok(())
}
}
}
fn generate_pdf_from_html(html: &str, out_path: &Path) -> Result<()> {
use fullbleed::FullBleed;
let engine = FullBleed::builder()
.document_title("RBAT Threat Intelligence Report")
.document_lang("en")
.build()
.map_err(|e| RbatError::PdfRenderError(e.to_string()))?;
let pdf_bytes = engine
.render_to_buffer(html, REPORT_CSS)
.map_err(|e| RbatError::PdfRenderError(e.to_string()))?;
fs::write(out_path, pdf_bytes)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_generate_pdf_report() {
let dir = tempdir().unwrap();
let out_path = dir.path().join("test_report.pdf");
let assessment = RiskAssessment::default();
let analysis = AnalysisResult::default();
let result = generate_pdf_report(Path::new("test_bin"), &assessment, &analysis, &out_path);
assert!(result.is_ok());
let html_path = dir.path().join("test_report.html");
assert!(out_path.exists() || html_path.exists());
}
}