diff --git a/.gitignore b/.gitignore index cdf50523..b32b62ba 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ test_* *.csv *.json *.jsonl -hayabusa* \ No newline at end of file +hayabusa* +*.html +*.htm +*.css \ No newline at end of file diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 2ed8031f..2c89d72b 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **新機能:** +- HTMLレポート機能の追加。 (#689) (@hitenkoku) + **改善:** - EventID解析のオプションをmetricsオプションに変更した。(旧: -s -> 新: -M) (#706) (@hitenkoku) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b13a1b..b7de01be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **New Features:** +- Added html summary output. (``-H, --html-report` option) (#689) (@hitenkoku) + **Enhancements:** - Changed Event ID Statistics option to Event ID Metrics option. (`-s, --statistics` -> `-M, --metrics`) (#706) (@hitenkoku) diff --git a/Cargo.lock b/Cargo.lock index e268df93..5b6b3458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -769,6 +769,7 @@ dependencies = [ "hashbrown", "hex", "hhmmss", + "horrorshow", "hyper", "is_elevated", "itertools", @@ -781,6 +782,7 @@ dependencies = [ "openssl", "pbr", "prettytable-rs", + "pulldown-cmark", "quick-xml", "rand", "regex", @@ -826,6 +828,12 @@ dependencies = [ "time 0.2.27", ] +[[package]] +name = "horrorshow" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8371fb981840150b1a54f7cb117bf6699f7466a1d4861daac33bc6fe2b5abea0" + [[package]] name = "http" version = "0.2.8" diff --git a/Cargo.toml b/Cargo.toml index 8c6df239..f1435d16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,9 @@ lock_api = "0.4.*" crossbeam-utils = "0.8.*" num-format = "*" comfy-table = "6.*" +pulldown-cmark = { version = "0.9.*", default-features = false, features = ["simd"] } reqwest = {version = "0.11.*", features = ["blocking", "json"]} +horrorshow = "0.8.*" [build-dependencies] static_vcruntime = "2.*" diff --git a/README-Japanese.md b/README-Japanese.md index bbd1ab02..2f34460f 100644 --- a/README-Japanese.md +++ b/README-Japanese.md @@ -393,6 +393,7 @@ ADVANCED: --target-file-ext ... evtx以外の拡張子を解析対象に追加する。 (例1: evtx_data 例2:evtx1 evtx2) OUTPUT: + -H, --html-report HTML形式で詳細な結果を出力する (例: results.html) -j, --json タイムラインの出力をJSON形式で保存する(例: -j -o results.json) -J, --jsonl タイムラインの出力をJSONL形式で保存する (例: -J -o results.jsonl) -o, --output タイムラインをCSV形式で保存する (例: results.csv) diff --git a/README.md b/README.md index d0174604..ab7e96be 100644 --- a/README.md +++ b/README.md @@ -384,10 +384,11 @@ ADVANCED: --target-file-ext ... Specify additional target file extensions (ex: evtx_data) (ex: evtx1 evtx2) OUTPUT: - -j, --json Save the timeline in JSON format (ex: -j -o results.json) - -J, --jsonl Save the timeline in JSONL format (ex: -J -o results.jsonl) - -o, --output Save the timeline in CSV format (ex: results.csv) - -P, --profile Specify output profile (minimal, standard, verbose, verbose-all-field-info, verbose-details-and-all-field-info) + -H, --html-report Save detail Results Summary in html (ex: results.html) + -j, --json Save the timeline in JSON format (ex: -j -o results.json) + -J, --jsonl Save the timeline in JSONL format (ex: -J -o results.jsonl) + -o, --output Save the timeline in CSV format (ex: results.csv) + -P, --profile Specify output profile (minimal, standard, verbose, verbose-all-field-info, verbose-details-and-all-field-info) DISPLAY-SETTINGS: --no-color Disable color output diff --git a/hayabusa_report.css b/hayabusa_report.css new file mode 100644 index 00000000..e6b930c9 --- /dev/null +++ b/hayabusa_report.css @@ -0,0 +1,4 @@ +h2 { + background-color: blue; + color: white; +} \ No newline at end of file diff --git a/rules b/rules index 2b0f88d1..aaf910cd 160000 --- a/rules +++ b/rules @@ -1 +1 @@ -Subproject commit 2b0f88d1c09b5b9979b99686a29a244993508210 +Subproject commit aaf910cdcaca32e89b0f81b0af4e180228d21eb6 diff --git a/src/afterfact.rs b/src/afterfact.rs index b8bc0ecb..7e57103a 100644 --- a/src/afterfact.rs +++ b/src/afterfact.rs @@ -4,6 +4,7 @@ use crate::detections::message::{self, LEVEL_ABBR}; use crate::detections::message::{AlertMessage, LEVEL_FULL}; use crate::detections::utils::{self, format_time}; use crate::detections::utils::{get_writable_color, write_color_buffer}; +use crate::options::htmlreport; use crate::options::profile::PROFILES; use bytesize::ByteSize; use chrono::{DateTime, Local, TimeZone, Utc}; @@ -210,6 +211,8 @@ fn emit_csv( all_record_cnt: u128, profile: LinkedHashMap, ) -> io::Result<()> { + let mut html_output_stock: Vec = vec![]; + let html_output_flag = configs::CONFIG.read().unwrap().args.html_report.is_some(); let disp_wtr = BufferWriter::stdout(ColorChoice::Always); let mut disp_wtr_buf = disp_wtr.buffer(); let json_output_flag = configs::CONFIG.read().unwrap().args.json_timeline; @@ -238,7 +241,7 @@ fn emit_csv( HashMap::new(); let mut detect_counts_by_rule_and_level: HashMap> = HashMap::new(); - + let mut rule_title_path_map: HashMap = HashMap::new(); let levels = Vec::from(["crit", "high", "med ", "low ", "info", "undefined"]); // レベル別、日ごとの集計用変数の初期化 for level_init in levels { @@ -372,6 +375,7 @@ fn emit_csv( .unwrap() }) .clone(); + rule_title_path_map.insert(detect_info.ruletitle.clone(), detect_info.rulepath.clone()); *detect_counts_by_rules .entry(Clone::clone(&detect_info.ruletitle)) .or_insert(0) += 1; @@ -454,31 +458,35 @@ fn emit_csv( ) .ok(); write_color_buffer(&disp_wtr, get_writable_color(None), ": ", false).ok(); + let saved_alerts_output = + (all_record_cnt - reducted_record_cnt).to_formatted_string(&Locale::en); write_color_buffer( &disp_wtr, get_writable_color(Some(Color::Rgb(255, 255, 0))), - &(all_record_cnt - reducted_record_cnt).to_formatted_string(&Locale::en), + &saved_alerts_output, false, ) .ok(); write_color_buffer(&disp_wtr, get_writable_color(None), " / ", false).ok(); + let all_record_output = all_record_cnt.to_formatted_string(&Locale::en); write_color_buffer( &disp_wtr, get_writable_color(Some(Color::Rgb(0, 255, 255))), - &all_record_cnt.to_formatted_string(&Locale::en), + &all_record_output, false, ) .ok(); write_color_buffer(&disp_wtr, get_writable_color(None), " (", false).ok(); + let reduction_output = format!( + "Data reduction: {} events ({:.2}%)", + reducted_record_cnt.to_formatted_string(&Locale::en), + reducted_percent + ); write_color_buffer( &disp_wtr, get_writable_color(Some(Color::Rgb(0, 255, 0))), - &format!( - "Data reduction: {} events ({:.2}%)", - reducted_record_cnt.to_formatted_string(&Locale::en), - reducted_percent - ), + &reduction_output, false, ) .ok(); @@ -487,6 +495,15 @@ fn emit_csv( println!(); println!(); + if html_output_flag { + html_output_stock.push(format!( + "- Saved alerts and events: {}", + &saved_alerts_output + )); + html_output_stock.push(format!("- Total events analyzed: {}", &all_record_output)); + html_output_stock.push(format!("- {}", reduction_output)); + } + _print_unique_results( total_detect_counts_by_level, unique_detect_counts_by_level, @@ -496,17 +513,44 @@ fn emit_csv( ); println!(); - _print_detection_summary_by_date(detect_counts_by_date_and_level, &color_map); + _print_detection_summary_by_date( + detect_counts_by_date_and_level, + &color_map, + &mut html_output_stock, + ); println!(); println!(); + if html_output_flag { + html_output_stock.push("".to_string()); + } - _print_detection_summary_by_computer(detect_counts_by_computer_and_level, &color_map); + _print_detection_summary_by_computer( + detect_counts_by_computer_and_level, + &color_map, + &mut html_output_stock, + ); println!(); + if html_output_flag { + html_output_stock.push("".to_string()); + } - _print_detection_summary_tables(detect_counts_by_rule_and_level, &color_map); + _print_detection_summary_tables( + detect_counts_by_rule_and_level, + &color_map, + rule_title_path_map, + &mut html_output_stock, + ); println!(); + if html_output_flag { + html_output_stock.push("".to_string()); + } + } + if html_output_flag { + htmlreport::add_md_data( + "Results Summary {#results_summary}".to_string(), + html_output_stock, + ); } - Ok(()) } @@ -634,13 +678,16 @@ fn _print_unique_results( fn _print_detection_summary_by_date( detect_counts_by_date: HashMap>, color_map: &HashMap, + html_output_stock: &mut Vec, ) { let buf_wtr = BufferWriter::stdout(ColorChoice::Always); let mut wtr = buf_wtr.buffer(); wtr.set_color(ColorSpec::new().set_fg(None)).ok(); - - writeln!(wtr, "Dates with most total detections:").ok(); - + let output_header = "Dates with most total detections:"; + writeln!(wtr, "{}", output_header).ok(); + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + html_output_stock.push(format!("- {}", output_header)); + } for (idx, level) in LEVEL_ABBR.values().enumerate() { // output_levelsはlevelsからundefinedを除外した配列であり、各要素は必ず初期化されているのでSomeであることが保証されているのでunwrapをそのまま実施 let detections_by_day = detect_counts_by_date.get(level).unwrap(); @@ -662,26 +709,28 @@ fn _print_detection_summary_by_date( if !exist_max_data { max_detect_str = "n/a".to_string(); } - write!( - wtr, + let output_str = format!( "{}: {}", LEVEL_FULL.get(level.as_str()).unwrap(), &max_detect_str - ) - .ok(); + ); + write!(wtr, "{}", output_str).ok(); if idx != LEVEL_ABBR.len() - 1 { wtr.set_color(ColorSpec::new().set_fg(None)).ok(); - write!(wtr, ", ").ok(); } + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + html_output_stock.push(format!(" - {}", output_str)); + } } buf_wtr.print(&wtr).ok(); } -/// 各レベル毎で最も高い検知数を出した日付を出力する +/// 各レベル毎で最も高い検知数を出したコンピュータ名を出力する fn _print_detection_summary_by_computer( detect_counts_by_computer: HashMap>, color_map: &HashMap, + html_output_stock: &mut Vec, ) { let buf_wtr = BufferWriter::stdout(ColorChoice::Always); let mut wtr = buf_wtr.buffer(); @@ -700,6 +749,22 @@ fn _print_detection_summary_by_computer( sorted_detections.sort_by(|a, b| (-a.1).cmp(&(-b.1))); + // html出力は各種すべてのコンピュータ名を表示するようにする + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + html_output_stock.push(format!( + "### Computers with most unique {} detections: {{#computers_with_most_unique_{}_detections}}", + LEVEL_FULL.get(level.as_str()).unwrap(), + LEVEL_FULL.get(level.as_str()).unwrap() + )); + for x in sorted_detections.iter() { + html_output_stock.push(format!( + "- {} ({})", + x.0, + x.1.to_formatted_string(&Locale::en) + )); + } + html_output_stock.push("".to_string()); + } for x in sorted_detections.iter().take(5) { result_vec.push(format!( "{} ({})", @@ -733,6 +798,8 @@ fn _print_detection_summary_by_computer( fn _print_detection_summary_tables( detect_counts_by_rule_and_level: HashMap>, color_map: &HashMap, + rule_title_path_map: HashMap, + html_output_stock: &mut Vec, ) { let buf_wtr = BufferWriter::stdout(ColorChoice::Always); let mut wtr = buf_wtr.buffer(); @@ -757,6 +824,27 @@ fn _print_detection_summary_tables( sorted_detections.sort_by(|a, b| (-a.1).cmp(&(-b.1))); + // html出力の場合はすべての内容を出力するようにする + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + html_output_stock.push(format!( + "### Top {} alerts: {{#top_{}_alerts}}", + LEVEL_FULL.get(level.as_str()).unwrap(), + LEVEL_FULL.get(level.as_str()).unwrap() + )); + for x in sorted_detections.iter() { + html_output_stock.push(format!( + "- [{}]({}) ({})", + x.0, + rule_title_path_map + .get(x.0) + .unwrap_or(&"".to_string()) + .replace('\\', "/"), + x.1.to_formatted_string(&Locale::en) + )); + } + html_output_stock.push("".to_string()); + } + let take_cnt = if LEVEL_FULL.get(level.as_str()).unwrap_or(&"-".to_string()) == "informational" { 10 diff --git a/src/detections/configs.rs b/src/detections/configs.rs index 0bd52d60..662d1b04 100644 --- a/src/detections/configs.rs +++ b/src/detections/configs.rs @@ -248,6 +248,10 @@ pub struct Config { /// Do not display result summary #[clap(help_heading = Some("DISPLAY-SETTINGS"), long = "no-summary")] pub no_summary: bool, + + /// Save detail Results Summary in html (ex: results.html) + #[clap(help_heading = Some("OUTPUT"), short = 'H', long="html-report", value_name = "FILE")] + pub html_report: Option, } impl ConfigReader<'_> { diff --git a/src/detections/detection.rs b/src/detections/detection.rs index ef428b6f..43581a7c 100644 --- a/src/detections/detection.rs +++ b/src/detections/detection.rs @@ -22,6 +22,7 @@ use crate::detections::rule::AggResult; use crate::detections::rule::RuleNode; use crate::detections::utils::{get_serde_number_to_string, make_ascii_titlecase}; use crate::filter; +use crate::options::htmlreport::{self}; use crate::yaml::ParseYaml; use hashbrown::HashMap; use serde_json::Value; @@ -605,6 +606,7 @@ impl Detection { let mut sorted_ld_rc: Vec<(&String, &u128)> = ld_rc.iter().collect(); sorted_ld_rc.sort_by(|a, b| a.0.cmp(b.0)); let args = &configs::CONFIG.read().unwrap().args; + let mut html_report_stock = Vec::new(); sorted_ld_rc.into_iter().for_each(|(key, value)| { if value != &0_u128 { @@ -614,12 +616,16 @@ impl Detection { "" }; //タイトルに利用するものはascii文字であることを前提として1文字目を大文字にするように変更する - println!( + let output_str = format!( "{} rules: {}{}", make_ascii_titlecase(key.clone().as_mut()), value, - disable_flag, + disable_flag ); + println!("{}", output_str); + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + html_report_stock.push(format!("- {}", output_str)); + } } }); if err_rc != &0_u128 { @@ -644,20 +650,24 @@ impl Detection { } else { "" }; + let output_str = format!( + "{} rules: {} ({:.2}%){}", + make_ascii_titlecase(key.clone().as_mut()), + value, + rate, + deprecated_flag + ); //タイトルに利用するものはascii文字であることを前提として1文字目を大文字にするように変更する write_color_buffer( &BufferWriter::stdout(ColorChoice::Always), None, - &format!( - "{} rules: {} ({:.2}%){}", - make_ascii_titlecase(key.clone().as_mut()), - value, - rate, - deprecated_flag - ), + &output_str, true, ) .ok(); + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + html_report_stock.push(format!("- {}", output_str)); + } } }); println!(); @@ -665,17 +675,32 @@ impl Detection { let mut sorted_rc: Vec<(&String, &u128)> = rc.iter().collect(); sorted_rc.sort_by(|a, b| a.0.cmp(b.0)); sorted_rc.into_iter().for_each(|(key, value)| { + let output_str = format!("{} rules: {}", key, value); write_color_buffer( &BufferWriter::stdout(ColorChoice::Always), None, - &format!("{} rules: {}", key, value), + &output_str, true, ) .ok(); + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + html_report_stock.push(format!("- {}", output_str)); + } }); - println!("Total enabled detection rules: {}", total_loaded_rule_cnt); + let tmp_total_detect_output = + format!("Total enabled detection rules: {}", total_loaded_rule_cnt); + println!("{}", tmp_total_detect_output); println!(); + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + html_report_stock.push(format!("- {}", tmp_total_detect_output)); + } + if !html_report_stock.is_empty() { + htmlreport::add_md_data( + "General Overview {#general_overview}".to_string(), + html_report_stock, + ); + } } } diff --git a/src/lib.rs b/src/lib.rs index 45a8b1e5..db666270 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,5 @@ pub mod timeline; pub mod yaml; #[macro_use] extern crate prettytable; +#[macro_use] +extern crate horrorshow; diff --git a/src/main.rs b/src/main.rs index 981ff7c9..d20fc8fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use hayabusa::detections::pivot::PivotKeyword; use hayabusa::detections::pivot::PIVOT_KEYWORD; use hayabusa::detections::rule::{get_detection_keys, RuleNode}; use hayabusa::omikuji::Omikuji; +use hayabusa::options::htmlreport::{self, HTML_REPORTER}; use hayabusa::options::profile::PROFILES; use hayabusa::options::{level_tuning::LevelTuning, update::Update}; use hayabusa::{afterfact::after_fact, detections::utils}; @@ -91,6 +92,17 @@ impl App { return; } let analysis_start_time: DateTime = Local::now(); + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + let output_data = vec![format!( + "- Start time: {}", + analysis_start_time.format("%Y/%m/%d %H:%M") + )]; + htmlreport::add_md_data( + "General Overview {#general_overview}".to_string(), + output_data, + ); + } + // Show usage when no arguments. if std::env::args().len() == 1 { self.output_logo(); @@ -107,7 +119,6 @@ impl App { &analysis_start_time.day().to_owned() )); } - if !self.is_matched_architecture_and_binary() { AlertMessage::alert( "The hayabusa version you ran does not match your PC architecture.\nPlease use the correct architecture. (Binary ending in -x64.exe for 64-bit and -x86.exe for 32-bit.)", @@ -251,6 +262,28 @@ impl App { println!(); } + if let Some(path) = &configs::CONFIG.read().unwrap().args.html_report { + // if already exists same html report file. output alert message and exit + if path.exists() { + AlertMessage::alert(&format!( + " The file {} already exists. Please specify a different filename.", + path.to_str().unwrap() + )) + .ok(); + return; + } + } + + write_color_buffer( + &BufferWriter::stdout(ColorChoice::Always), + None, + &format!( + "Start time: {}\n", + analysis_start_time.format("%Y/%m/%d %H:%M") + ), + true, + ) + .ok(); if configs::CONFIG.read().unwrap().args.live_analysis { let live_analysis_list = self.collect_liveanalysis_files(); if live_analysis_list.is_none() { @@ -359,15 +392,22 @@ impl App { let analysis_end_time: DateTime = Local::now(); let analysis_duration = analysis_end_time.signed_duration_since(analysis_start_time); + let elapsed_output_str = format!("Elapsed Time: {}", &analysis_duration.hhmmssxxx()); write_color_buffer( &BufferWriter::stdout(ColorChoice::Always), None, - &format!("Elapsed Time: {}", &analysis_duration.hhmmssxxx()), + &elapsed_output_str, true, ) .ok(); println!(); - + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + let output_data = vec![format!("- {}", elapsed_output_str)]; + htmlreport::add_md_data( + "General Overview {#general_overview}".to_string(), + output_data, + ); + } // Qオプションを付けた場合もしくはパースのエラーがない場合はerrorのstackが0となるのでエラーログファイル自体が生成されない。 if ERROR_LOG_STACK.lock().unwrap().len() > 0 { AlertMessage::create_error_log(ERROR_LOG_PATH.to_string()); @@ -445,6 +485,22 @@ impl App { }); } } + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + let html_str = HTML_REPORTER.read().unwrap().clone().create_html(); + htmlreport::create_html_file( + html_str, + configs::CONFIG + .read() + .unwrap() + .args + .html_report + .as_ref() + .unwrap() + .to_str() + .unwrap_or("") + .to_string(), + ) + } } #[cfg(not(target_os = "windows"))] @@ -541,7 +597,6 @@ impl App { } } } - fn analysis_files(&mut self, evtx_files: Vec, time_filter: &TargetEventTime) { let level = configs::CONFIG .read() @@ -562,11 +617,23 @@ impl App { let meta = fs::metadata(file_path).ok(); total_file_size += ByteSize::b(meta.unwrap().len()); } - println!("Total file size: {}", total_file_size.to_string_as(false)); + let total_size_output = format!("Total file size: {}", total_file_size.to_string_as(false)); + println!("{}", total_size_output); println!(); println!("Loading detections rules. Please wait."); println!(); + if configs::CONFIG.read().unwrap().args.html_report.is_some() { + let output_data = vec![ + format!("- Analyzed event files: {}", evtx_files.len()), + format!("- {}", total_size_output), + ]; + htmlreport::add_md_data( + "General Overview #{general_overview}".to_string(), + output_data, + ); + } + let rule_files = detection::Detection::parse_rule_files( level, &configs::CONFIG.read().unwrap().args.rules, diff --git a/src/options/htmlreport.rs b/src/options/htmlreport.rs new file mode 100644 index 00000000..89826a1e --- /dev/null +++ b/src/options/htmlreport.rs @@ -0,0 +1,160 @@ +use hashbrown::HashMap; +use horrorshow::helper::doctype; +use horrorshow::prelude::*; +use lazy_static::lazy_static; +use pulldown_cmark::{html, Options, Parser}; +use std::fs::create_dir; +use std::fs::File; +use std::io::BufWriter; +use std::io::Write; +use std::path::Path; +use std::sync::RwLock; + +lazy_static! { + pub static ref HTML_REPORTER: RwLock = RwLock::new(HtmlReporter::new()); +} + +#[derive(Clone)] +pub struct HtmlReporter { + pub section_order: Vec, + pub md_datas: HashMap>, +} + +impl HtmlReporter { + pub fn new() -> HtmlReporter { + let (init_section_order, init_data) = get_init_md_data_map(); + HtmlReporter { + section_order: init_section_order, + md_datas: init_data, + } + } + + /// return converted String from md_data(markdown fmt string). + pub fn create_html(self) -> String { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_HEADING_ATTRIBUTES); + options.insert(Options::ENABLE_FOOTNOTES); + + let mut md_data = vec![]; + for section_name in self.section_order { + if let Some(v) = self.md_datas.get(§ion_name) { + md_data.push(format!("## {}\n", §ion_name)); + if v.is_empty() { + md_data.push("not found data.\n".to_string()); + } else { + md_data.push(v.join("\n")); + } + } + } + let md_str = md_data.join("\n"); + let parser = Parser::new_ext(&md_str, options); + + let mut ret = String::new(); + html::push_html(&mut ret, parser); + ret + } +} + +impl Default for HtmlReporter { + fn default() -> Self { + Self::new() + } +} + +/// get html report section data in LinkedHashMap +fn get_init_md_data_map() -> (Vec, HashMap>) { + let mut ret = HashMap::new(); + let section_order = vec![ + "General Overview {#general_overview}".to_string(), + "Results Summary {#results_summary}".to_string(), + ]; + for section in section_order.iter() { + ret.insert(section.to_owned(), vec![]); + } + + (section_order, ret) +} + +pub fn add_md_data(section_name: String, data: Vec) { + let mut md_with_section_data = HTML_REPORTER.write().unwrap().md_datas.clone(); + for c in data { + let entry = md_with_section_data + .entry(section_name.clone()) + .or_insert(Vec::new()); + entry.push(c); + } + HTML_REPORTER.write().unwrap().md_datas = md_with_section_data; +} + +/// create html file +pub fn create_html_file(input_html: String, path_str: String) { + let path = Path::new(&path_str); + if !path.parent().unwrap().exists() { + create_dir(path.parent().unwrap()).ok(); + } + + let mut html_writer = BufWriter::new(File::create(path).unwrap()); + let html_data = format!( + "{}", + html! { + : doctype::HTML; + html { + head { + meta(charset="UTF-8"); + link(rel="stylesheet", type="text/css", href="./hayabusa_report.css"); + link(rel="icon", type="image/png", href="./favicon.png"); + } + body : Raw(input_html.clone().as_str()) + } + } + ); + + writeln!(html_writer, "{}", html_data).ok(); + println!( + "HTML Report was generated. Please check {} for details.", + path_str + ); + println!(); +} + +#[cfg(test)] +mod tests { + + use crate::options::htmlreport::HtmlReporter; + + #[test] + fn test_create_html() { + let mut html_reporter = HtmlReporter::new(); + let general_data = vec![ + "- Analyzed event files: 581".to_string(), + "- Total file size: 148.5 MB".to_string(), + "- Excluded rules: 12".to_string(), + "- Noisy rules: 5 (Disabled)".to_string(), + "- Experimental rules: 1935 (65.97%)".to_string(), + "- Stable rules: 215 (7.33%)".to_string(), + "- Test rules: 783 (26.70%)".to_string(), + "- Hayabusa rules: 138".to_string(), + "- Sigma rules: 2795".to_string(), + "- Total enabled detection rules: 2933".to_string(), + "- Elapsed Time: 00:00:29.035".to_string(), + "".to_string(), + ]; + html_reporter.md_datas.insert( + "General Overview {#general_overview}".to_string(), + general_data.clone(), + ); + let general_overview_str = format!( + "
    \n
  • {}
  • \n
", + general_data[..general_data.len() - 1] + .join("\n
  • ") + .replace("- ", "") + ); + let expect_str = format!( + "

    General Overview

    \n{}\n

    Results Summary

    \n

    not found data.

    \n", + general_overview_str + ); + + assert_eq!(html_reporter.create_html(), expect_str); + } +} diff --git a/src/options/mod.rs b/src/options/mod.rs index 1f123528..26cca6fb 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -1,3 +1,4 @@ +pub mod htmlreport; pub mod level_tuning; pub mod profile; pub mod update;