diff --git a/README.md b/README.md index d98311e..69b6b45 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,15 @@ pqcscan tls-scan -t pq.cloudflareresearch.com:443 -o cloudflare.json pqcscan create-report -i gmail.json cloudflare.json -o report.html ``` +To generate CSV, JSON or XML output for spreadsheet import, asset inventory or +automation workflows, set the report format explicitly: + +``` +pqcscan create-report -i gmail.json cloudflare.json --format csv -o report.csv +pqcscan create-report -i gmail.json cloudflare.json --format json -o report.json +pqcscan create-report -i gmail.json cloudflare.json --format xml -o report.xml +``` + You can also create a target list in a file and supply it via `-T`. This works for both `tls-scan` and `ssh-scan`. ``` diff --git a/TODO b/TODO index 09b7dba..fe32ed4 100644 --- a/TODO +++ b/TODO @@ -11,4 +11,6 @@ and rewrite the templates that all the elements are built up via pure JS instead of generated HTML -- [ ] add some combined JSON/CSV/XML export output on top of the HTML format +- [x] add some combined JSON/CSV/XML export output on top of the HTML format + - [x] CSV report export + - [x] JSON/XML report export diff --git a/src/main.rs b/src/main.rs index 1fdd1fb..fa2157a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use serde::Serialize; use std::collections::{BTreeSet, HashMap}; use std::convert::From; use std::fs::File; -use std::io::{BufRead, BufReader, BufWriter}; +use std::io::{BufRead, BufReader, BufWriter, Write}; use std::path::PathBuf; use std::sync::Arc; use tera::{Context, Tera}; @@ -126,7 +126,30 @@ struct ScanWindow { scan_type: ScanType, } -fn create_report(output_file: &str, input_files: &Vec<&String>) -> Result<()> { +#[derive(Clone, Copy)] +enum ReportFormat { + Html, + Csv, + Json, + Xml, +} + +impl From<&str> for ReportFormat { + fn from(value: &str) -> Self { + match value { + "csv" => ReportFormat::Csv, + "json" => ReportFormat::Json, + "xml" => ReportFormat::Xml, + _ => ReportFormat::Html, + } + } +} + +fn create_report( + output_file: &str, + input_files: &Vec<&String>, + report_format: ReportFormat, +) -> Result<()> { log::debug!("Initializing report data structures"); let mut tls_map: HashMap> = HashMap::new(); let mut ssh_map: HashMap> = HashMap::new(); @@ -253,6 +276,15 @@ fn create_report(output_file: &str, input_files: &Vec<&String>) -> Result<()> { scan_windows: scan_windows, }; + match report_format { + ReportFormat::Html => render_html_report(output_file, &results), + ReportFormat::Csv => write_csv_report(output_file, &results), + ReportFormat::Json => write_json_report(output_file, &results), + ReportFormat::Xml => write_xml_report(output_file, &results), + } +} + +fn render_html_report(output_file: &str, results: &ReportResults) -> Result<()> { let templates = [ "macros.html", "template.html", @@ -284,6 +316,329 @@ fn create_report(output_file: &str, input_files: &Vec<&String>) -> Result<()> { Ok(()) } +fn write_csv_report(output_file: &str, results: &ReportResults) -> Result<()> { + log::debug!("Writing CSV report to {}", output_file); + let f = File::create(output_file)?; + let mut writer = BufWriter::new(f); + write_csv_report_to_writer(&mut writer, results)?; + writer.flush()?; + log::info!("CSV report written to {}", output_file); + Ok(()) +} + +fn write_json_report(output_file: &str, results: &ReportResults) -> Result<()> { + log::debug!("Writing JSON report to {}", output_file); + let f = File::create(output_file)?; + let mut writer = BufWriter::new(f); + serde_json::to_writer_pretty(&mut writer, results)?; + writer.write_all(b"\n")?; + writer.flush()?; + log::info!("JSON report written to {}", output_file); + Ok(()) +} + +fn write_xml_report(output_file: &str, results: &ReportResults) -> Result<()> { + log::debug!("Writing XML report to {}", output_file); + let f = File::create(output_file)?; + let mut writer = BufWriter::new(f); + write_xml_report_to_writer(&mut writer, results)?; + writer.flush()?; + log::info!("XML report written to {}", output_file); + Ok(()) +} + +fn write_xml_report_to_writer(writer: &mut W, results: &ReportResults) -> Result<()> { + writer.write_all(br#""#)?; + writer.write_all(b"\n\n")?; + writeln!(writer, " ")?; + writeln!( + writer, + " ", + results.ssh_total_count, + results.ssh_success_count, + results.ssh_fail_count, + results.ssh_pqc_supported_count + )?; + writeln!( + writer, + " ", + results.tls_total_count, + results.tls_success_count, + results.tls_fail_count, + results.tls_pqc_supported_count + )?; + writeln!(writer, " ")?; + writeln!(writer, " ")?; + for window in &results.scan_windows { + writeln!( + writer, + " ", + scan_type_name(&window.scan_type), + xml_escape(&window.start_time.to_rfc3339()), + xml_escape(&window.end_time.to_rfc3339()) + )?; + } + writeln!(writer, " ")?; + writeln!(writer, " ")?; + + for host in &results.ssh_sorted_hosts { + if let Some(host_results) = results.ssh_results.get(host) { + for result in host_results { + if let ScanResult::Ssh { + targetspec, + addr, + error, + pqc_supported, + pqc_algos, + nonpqc_algos, + } = result + { + write_xml_result( + writer, + "ssh", + targetspec, + addr, + error, + *pqc_supported, + pqc_algos, + &None, + nonpqc_algos, + )?; + } + } + } + } + + for host in &results.tls_sorted_hosts { + if let Some(host_results) = results.tls_results.get(host) { + for result in host_results { + if let ScanResult::Tls { + targetspec, + addr, + error, + pqc_supported, + pqc_algos, + hybrid_algos, + nonpqc_algos, + } = result + { + write_xml_result( + writer, + "tls", + targetspec, + addr, + error, + *pqc_supported, + pqc_algos, + hybrid_algos, + nonpqc_algos, + )?; + } + } + } + } + + writeln!(writer, " ")?; + writeln!(writer, "")?; + Ok(()) +} + +fn write_xml_result( + writer: &mut W, + scan_type: &str, + targetspec: &Target, + addr: &Option, + error: &Option, + pqc_supported: bool, + pqc_algos: &Option>, + hybrid_algos: &Option>, + nonpqc_algos: &Option>, +) -> Result<()> { + writeln!(writer, " ", scan_type)?; + write_xml_text_element(writer, 6, "host", &targetspec.host)?; + write_xml_text_element(writer, 6, "port", &targetspec.port.to_string())?; + write_xml_text_element(writer, 6, "address", &addr.clone().unwrap_or_default())?; + write_xml_text_element(writer, 6, "status", status(error))?; + write_xml_text_element(writer, 6, "pqc_supported", &pqc_supported.to_string())?; + write_xml_algorithm_list(writer, 6, "pqc_algorithms", pqc_algos)?; + write_xml_algorithm_list(writer, 6, "hybrid_algorithms", hybrid_algos)?; + write_xml_algorithm_list(writer, 6, "non_pqc_algorithms", nonpqc_algos)?; + write_xml_text_element(writer, 6, "error", &error.clone().unwrap_or_default())?; + writeln!(writer, " ")?; + Ok(()) +} + +fn write_xml_algorithm_list( + writer: &mut W, + indent: usize, + name: &str, + algorithms: &Option>, +) -> Result<()> { + writeln!(writer, "{:indent$}<{}>", "", name, indent = indent)?; + if let Some(algorithms) = algorithms { + for algorithm in algorithms { + write_xml_text_element(writer, indent + 2, "algorithm", algorithm)?; + } + } + writeln!(writer, "{:indent$}", "", name, indent = indent)?; + Ok(()) +} + +fn write_xml_text_element( + writer: &mut W, + indent: usize, + name: &str, + value: &str, +) -> Result<()> { + writeln!( + writer, + "{:indent$}<{}>{}", + "", + name, + xml_escape(value), + name, + indent = indent + )?; + Ok(()) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn scan_type_name(scan_type: &ScanType) -> &'static str { + match scan_type { + ScanType::Ssh => "ssh", + ScanType::Tls => "tls", + } +} + +fn write_csv_report_to_writer(writer: &mut W, results: &ReportResults) -> Result<()> { + write_csv_record( + writer, + &[ + "scan_type", + "host", + "port", + "address", + "status", + "pqc_supported", + "pqc_algorithms", + "hybrid_algorithms", + "non_pqc_algorithms", + "error", + ], + )?; + + for host in &results.ssh_sorted_hosts { + if let Some(host_results) = results.ssh_results.get(host) { + for result in host_results { + if let ScanResult::Ssh { + targetspec, + addr, + error, + pqc_supported, + pqc_algos, + nonpqc_algos, + } = result + { + write_csv_record( + writer, + &[ + "ssh".to_string(), + targetspec.host.clone(), + targetspec.port.to_string(), + addr.clone().unwrap_or_default(), + status(error).to_string(), + pqc_supported.to_string(), + join_algorithms(pqc_algos), + String::new(), + join_algorithms(nonpqc_algos), + error.clone().unwrap_or_default(), + ], + )?; + } + } + } + } + + for host in &results.tls_sorted_hosts { + if let Some(host_results) = results.tls_results.get(host) { + for result in host_results { + if let ScanResult::Tls { + targetspec, + addr, + error, + pqc_supported, + pqc_algos, + hybrid_algos, + nonpqc_algos, + } = result + { + write_csv_record( + writer, + &[ + "tls".to_string(), + targetspec.host.clone(), + targetspec.port.to_string(), + addr.clone().unwrap_or_default(), + status(error).to_string(), + pqc_supported.to_string(), + join_algorithms(pqc_algos), + join_algorithms(hybrid_algos), + join_algorithms(nonpqc_algos), + error.clone().unwrap_or_default(), + ], + )?; + } + } + } + } + Ok(()) +} + +fn status(error: &Option) -> &'static str { + if error.is_some() { + "error" + } else { + "success" + } +} + +fn join_algorithms(algorithms: &Option>) -> String { + algorithms + .as_ref() + .map(|items| items.join(";")) + .unwrap_or_default() +} + +fn write_csv_record(writer: &mut W, fields: &[impl AsRef]) -> Result<()> { + for (idx, field) in fields.iter().enumerate() { + if idx > 0 { + writer.write_all(b",")?; + } + write_csv_field(writer, field.as_ref())?; + } + writer.write_all(b"\n")?; + Ok(()) +} + +fn write_csv_field(writer: &mut W, field: &str) -> Result<()> { + if field.chars().any(|c| matches!(c, ',' | '"' | '\n' | '\r')) { + writer.write_all(b"\"")?; + writer.write_all(field.replace('"', "\"\"").as_bytes())?; + writer.write_all(b"\"")?; + } else { + writer.write_all(field.as_bytes())?; + } + Ok(()) +} + fn main() -> Result<()> { env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); @@ -337,7 +692,7 @@ fn main() -> Result<()> { ) .subcommand( Command::new("create-report") - .about("Convert JSON results to HTML report") + .about("Convert JSON results to HTML or CSV report") .next_help_heading("Input") .args(vec![Arg::new("input") .short('i') @@ -346,7 +701,14 @@ fn main() -> Result<()> { .help("JSON file(s) containing scan results ") .num_args(0..)]) .next_help_heading("Output") - .args(output_args("HTML", true)) + .args(output_args("report", true)) + .arg( + Arg::new("format") + .long("format") + .value_parser(["html", "csv", "json", "xml"]) + .default_value("html") + .help("Report output format"), + ) .disable_help_flag(true) .disable_version_flag(true), ) @@ -399,7 +761,9 @@ fn main() -> Result<()> { output_json_file = sub_matches.get_one::("output"); } Some(("create-report", sub_matches)) => { - log::info!("Creating HTML report from JSON results"); + let report_format = + ReportFormat::from(sub_matches.get_one::("format").unwrap().as_str()); + log::info!("Creating report from JSON results"); let input_files: Vec<_> = sub_matches .get_many::("input") .ok_or(anyhow!( @@ -410,6 +774,7 @@ fn main() -> Result<()> { create_report( sub_matches.get_one::("output").unwrap(), &input_files, + report_format, )?; log::info!("Report created successfully"); } @@ -446,3 +811,136 @@ fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_csv_record_escapes_special_characters() { + let mut output = Vec::new(); + + write_csv_record( + &mut output, + &["plain", "with,comma", "with \"quote\"", "with\nnewline", ""], + ) + .unwrap(); + + assert_eq!( + String::from_utf8(output).unwrap(), + "plain,\"with,comma\",\"with \"\"quote\"\"\",\"with\nnewline\",\n" + ); + } + + #[test] + fn write_csv_report_includes_ssh_and_tls_rows() { + let mut ssh_results = HashMap::new(); + ssh_results.insert( + "ssh.example.com".to_string(), + vec![ScanResult::Ssh { + targetspec: Target { + host: "ssh.example.com".to_string(), + port: 22, + }, + addr: Some("203.0.113.10:22".to_string()), + error: None, + pqc_supported: true, + pqc_algos: Some(vec!["sntrup761x25519-sha512".to_string()]), + nonpqc_algos: Some(vec!["curve25519-sha256".to_string()]), + }], + ); + + let mut tls_results = HashMap::new(); + tls_results.insert( + "tls.example.com".to_string(), + vec![ScanResult::Tls { + targetspec: Target { + host: "tls.example.com".to_string(), + port: 443, + }, + addr: None, + error: Some("timeout, retry later".to_string()), + pqc_supported: false, + pqc_algos: Some(vec![]), + hybrid_algos: Some(vec!["X25519MLKEM768".to_string()]), + nonpqc_algos: Some(vec!["x25519".to_string()]), + }], + ); + + let results = ReportResults { + tls_results, + tls_sorted_hosts: BTreeSet::from(["tls.example.com".to_string()]), + tls_success_count: 0, + tls_fail_count: 1, + tls_pqc_supported_count: 0, + tls_total_count: 1, + ssh_results, + ssh_sorted_hosts: BTreeSet::from(["ssh.example.com".to_string()]), + ssh_success_count: 1, + ssh_fail_count: 0, + ssh_pqc_supported_count: 1, + ssh_total_count: 1, + scan_windows: vec![], + }; + + let mut output = Vec::new(); + write_csv_report_to_writer(&mut output, &results).unwrap(); + + assert_eq!( + String::from_utf8(output).unwrap(), + concat!( + "scan_type,host,port,address,status,pqc_supported,pqc_algorithms,hybrid_algorithms,non_pqc_algorithms,error\n", + "ssh,ssh.example.com,22,203.0.113.10:22,success,true,sntrup761x25519-sha512,,curve25519-sha256,\n", + "tls,tls.example.com,443,,error,false,,X25519MLKEM768,x25519,\"timeout, retry later\"\n", + ) + ); + } + + #[test] + fn write_xml_report_escapes_values() { + let mut ssh_results = HashMap::new(); + ssh_results.insert( + "ssh.example.com".to_string(), + vec![ScanResult::Ssh { + targetspec: Target { + host: "ssh.example.com".to_string(), + port: 22, + }, + addr: Some("203.0.113.10:22".to_string()), + error: Some("bad \"quote\" & ".to_string()), + pqc_supported: false, + pqc_algos: Some(vec!["sntrup761x25519-sha512".to_string()]), + nonpqc_algos: Some(vec!["curve25519-sha256".to_string()]), + }], + ); + + let results = ReportResults { + tls_results: HashMap::new(), + tls_sorted_hosts: BTreeSet::new(), + tls_success_count: 0, + tls_fail_count: 0, + tls_pqc_supported_count: 0, + tls_total_count: 0, + ssh_results, + ssh_sorted_hosts: BTreeSet::from(["ssh.example.com".to_string()]), + ssh_success_count: 0, + ssh_fail_count: 1, + ssh_pqc_supported_count: 0, + ssh_total_count: 1, + scan_windows: vec![ScanWindow { + start_time: Utc::now(), + end_time: Utc::now(), + scan_type: ScanType::Ssh, + }], + }; + + let mut output = Vec::new(); + write_xml_report_to_writer(&mut output, &results).unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains(r#""#)); + assert!(output.contains(r#""#)); + assert!(output.contains("sntrup761x25519-sha512")); + assert!(output.contains("bad "quote" & <tag>")); + } +} diff --git a/support/templates/tls_results.html b/support/templates/tls_results.html index 9b74621..2ae2b4a 100644 --- a/support/templates/tls_results.html +++ b/support/templates/tls_results.html @@ -68,18 +68,20 @@
{{hostname}}
{% if result['pqc_supported'] %} {{ result['pqc_supported']}} - PQC Algorithms - - {% if result['pqc_algos'] and result['pqc_algos'].count > 0 %} - {% for algo in result['pqc_algos'] | sort %} - {{ algo }}{% if not loop.last %}, {% endif %} - {% endfor %} - {% endif %} - {% for algo in result['hybrid_algos'] | sort %} - {{ algo }}{% if not loop.last %}, {% endif %} - {% endfor %} - - + PQC Algorithms + + {% if result['pqc_algos'] and result['pqc_algos'] | length > 0 %} + {% for algo in result['pqc_algos'] | sort %} + {{ algo }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% endif %} + {% if result['hybrid_algos'] %} + {% for algo in result['hybrid_algos'] | sort %} + {{ algo }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% endif %} + + {% else %} {{ result['pqc_supported']}} {% endif %} @@ -93,4 +95,4 @@
{{hostname}}
{% endfor %} -{% endfor %} \ No newline at end of file +{% endfor %}