diff --git a/src/detections/configs.rs b/src/detections/configs.rs index 5e5062fad..8ff4ca812 100644 --- a/src/detections/configs.rs +++ b/src/detections/configs.rs @@ -110,6 +110,7 @@ pub struct StoredStatic { pub logon_summary_flag: bool, pub search_flag: bool, pub computer_metrics_flag: bool, + pub log_metrics_flag: bool, pub search_option: Option, pub output_option: Option, pub pivot_keyword_list_flag: bool, @@ -151,6 +152,7 @@ impl StoredStatic { Some(Action::PivotKeywordsList(opt)) => opt.detect_common_options.quiet_errors, Some(Action::Search(opt)) => opt.quiet_errors, Some(Action::ComputerMetrics(opt)) => opt.quiet_errors, + Some(Action::LogMetrics(opt)) => opt.detect_common_options.quiet_errors, _ => false, }; let common_options = match &input_config.as_ref().unwrap().action { @@ -165,6 +167,7 @@ impl StoredStatic { Some(Action::UpdateRules(opt)) => opt.common_options, Some(Action::Search(opt)) => opt.common_options, Some(Action::ComputerMetrics(opt)) => opt.common_options, + Some(Action::LogMetrics(opt)) => opt.common_options, None => CommonOptions { no_color: false, quiet: false, @@ -180,6 +183,7 @@ impl StoredStatic { Some(Action::PivotKeywordsList(opt)) => &opt.detect_common_options.config, Some(Action::Search(opt)) => &opt.config, Some(Action::ComputerMetrics(opt)) => &opt.config, + Some(Action::LogMetrics(opt)) => &opt.detect_common_options.config, _ => &binding, }; let verbose_flag = match &input_config.as_ref().unwrap().action { @@ -190,6 +194,7 @@ impl StoredStatic { Some(Action::PivotKeywordsList(opt)) => opt.detect_common_options.verbose, Some(Action::Search(opt)) => opt.verbose, Some(Action::ComputerMetrics(opt)) => opt.verbose, + Some(Action::LogMetrics(opt)) => opt.detect_common_options.verbose, _ => false, }; let json_input_flag = match &input_config.as_ref().unwrap().action { @@ -199,6 +204,7 @@ impl StoredStatic { Some(Action::EidMetrics(opt)) => opt.detect_common_options.json_input, Some(Action::PivotKeywordsList(opt)) => opt.detect_common_options.json_input, Some(Action::ComputerMetrics(opt)) => opt.json_input, + Some(Action::LogMetrics(opt)) => opt.detect_common_options.json_input, _ => false, }; let is_valid_min_level = match &input_config.as_ref().unwrap().action { @@ -347,6 +353,7 @@ impl StoredStatic { Some(Action::LogonSummary(opt)) => opt.output.as_ref(), Some(Action::Search(opt)) => opt.output.as_ref(), Some(Action::ComputerMetrics(opt)) => opt.output.as_ref(), + Some(Action::LogMetrics(opt)) => opt.output.as_ref(), _ => None, }; let general_ch_abbr = create_output_filter_config( @@ -570,6 +577,7 @@ impl StoredStatic { Some(Action::LogonSummary(opt)) => opt.input_args.recover_records, Some(Action::PivotKeywordsList(opt)) => opt.input_args.recover_records, Some(Action::Search(opt)) => opt.input_args.recover_records, + Some(Action::LogMetrics(opt)) => opt.input_args.recover_records, _ => false, }; let timeline_offset = match &input_config.as_ref().unwrap().action { @@ -582,6 +590,7 @@ impl StoredStatic { Some(Action::PivotKeywordsList(opt)) => opt.input_args.timeline_offset.clone(), Some(Action::Search(opt)) => opt.input_args.timeline_offset.clone(), Some(Action::ComputerMetrics(opt)) => opt.input_args.timeline_offset.clone(), + Some(Action::LogMetrics(opt)) => opt.input_args.timeline_offset.clone(), _ => None, }; let include_status: HashSet = match &input_config.as_ref().unwrap().action { @@ -692,6 +701,7 @@ impl StoredStatic { metrics_flag: action_id == 3, search_flag: action_id == 10, computer_metrics_flag: action_id == 11, + log_metrics_flag: action_id == 12, search_option: extract_search_options(input_config.as_ref().unwrap()), output_option: extract_output_options(input_config.as_ref().unwrap()), pivot_keyword_list_flag: action_id == 4, @@ -830,6 +840,7 @@ fn check_thread_number(config: &Config) -> Option { Action::LogonSummary(opt) => opt.detect_common_options.thread_number, Action::EidMetrics(opt) => opt.detect_common_options.thread_number, Action::PivotKeywordsList(opt) => opt.detect_common_options.thread_number, + Action::LogMetrics(opt) => opt.detect_common_options.thread_number, _ => None, } } @@ -857,6 +868,16 @@ pub enum Action { /// Save the timeline in JSON/JSONL format. JsonTimeline(JSONOutputOption), + #[clap( + author = "Yamato Security (https://github.com/Yamato-Security/hayabusa - @SecurityYamato)", + help_template = "\nHayabusa v2.19.0 - Dev Build\n{author-with-newline}\n{usage-heading}\n hayabusa.exe log-metrics [OPTIONS]\n\n{all-args}", + term_width = 400, + display_order = 382, + disable_help_flag = true + )] + /// Print log file metrics + LogMetrics(LogMetricsOption), + #[clap( author = "Yamato Security (https://github.com/Yamato-Security/hayabusa - @SecurityYamato)", help_template = "\nHayabusa v2.19.0 - Dev Build\n{author-with-newline}\n{usage-heading}\n hayabusa.exe logon-summary [OPTIONS]\n\n{all-args}", @@ -962,6 +983,7 @@ impl Action { Action::ListProfiles(_) => 9, Action::Search(_) => 10, Action::ComputerMetrics(_) => 11, + Action::LogMetrics(_) => 12, } } else { 100 @@ -982,6 +1004,7 @@ impl Action { Action::ListProfiles(_) => "list-profiles", Action::Search(_) => "search", Action::ComputerMetrics(_) => "computer-metrics", + Action::LogMetrics(_) => "log-metrics", } } else { "" @@ -1781,6 +1804,54 @@ pub struct ComputerMetricsOption { pub clobber: bool, } +#[derive(Args, Clone, Debug)] +pub struct LogMetricsOption { + #[clap(flatten)] + pub input_args: InputOption, + + /// Save the Metrics in CSV format (ex: metrics.csv) + #[arg(help_heading = Some("Output"), short = 'o', long, value_name = "FILE", display_order = 410)] + pub output: Option, + + #[clap(flatten)] + pub common_options: CommonOptions, + + #[clap(flatten)] + pub detect_common_options: DetectCommonOption, + + /// Output timestamp in European time format (ex: 22-02-2022 22:00:00.123 +02:00) + #[arg(help_heading = Some("Time Format"), long = "European-time", display_order = 50)] + pub european_time: bool, + + /// Output timestamp in original ISO-8601 format (ex: 2022-02-22T10:10:10.1234567Z) (Always UTC) + #[arg(help_heading = Some("Time Format"), short = 'O', long = "ISO-8601", display_order = 90)] + pub iso_8601: bool, + + /// Output timestamp in RFC 2822 format (ex: Fri, 22 Feb 2022 22:00:00 -0600) + #[arg(help_heading = Some("Time Format"), long = "RFC-2822", display_order = 180)] + pub rfc_2822: bool, + + /// Output timestamp in RFC 3339 format (ex: 2022-02-22 22:00:00.123456-06:00) + #[arg(help_heading = Some("Time Format"), long = "RFC-3339", display_order = 180)] + pub rfc_3339: bool, + + /// Output timestamp in US military time format (ex: 02-22-2022 22:00:00.123 -06:00) + #[arg(help_heading = Some("Time Format"), long = "US-military-time", display_order = 210)] + pub us_military_time: bool, + + /// Output timestamp in US time format (ex: 02-22-2022 10:00:00.123 PM -06:00) + #[arg(help_heading = Some("Time Format"), long = "US-time", display_order = 210)] + pub us_time: bool, + + /// Output time in UTC format (default: local time) + #[arg(help_heading = Some("Time Format"), short = 'U', long = "UTC", display_order = 210)] + pub utc: bool, + + /// Overwrite files when saving + #[arg(help_heading = Some("General Options"), short='C', long = "clobber", display_order = 290, requires = "output")] + pub clobber: bool, +} + #[derive(Parser, Clone, Debug)] #[clap( author = "Yamato Security (https://github.com/Yamato-Security/hayabusa - @SecurityYamato)", @@ -2462,6 +2533,49 @@ fn extract_output_options(config: &Config) -> Option { enable_all_rules: false, scan_all_evtx_files: false, }), + Action::LogMetrics(option) => Some(OutputOption { + input_args: option.input_args.clone(), + profile: None, + common_options: option.common_options, + enable_deprecated_rules: false, + enable_unsupported_rules: false, + exclude_status: None, + include_tag: None, + include_category: None, + exclude_category: None, + min_level: String::default(), + exact_level: None, + enable_noisy_rules: false, + end_timeline: None, + start_timeline: None, + eid_filter: false, + proven_rules: false, + exclude_tag: None, + detect_common_options: option.detect_common_options.clone(), + european_time: false, + iso_8601: false, + rfc_2822: false, + rfc_3339: false, + us_military_time: false, + us_time: false, + utc: false, + visualize_timeline: false, + rules: Path::new("./rules").to_path_buf(), + html_report: None, + no_summary: false, + clobber: option.clobber, + include_eid: None, + exclude_eid: None, + no_field: false, + no_pwsh_field_extraction: false, + remove_duplicate_data: false, + remove_duplicate_detections: false, + no_wizard: true, + include_status: None, + sort_events: false, + enable_all_rules: false, + scan_all_evtx_files: false, + }), Action::Search(option) => Some(OutputOption { input_args: option.input_args.clone(), enable_deprecated_rules: false, diff --git a/src/detections/detection.rs b/src/detections/detection.rs index 3b7e11bcf..03f0b53e0 100644 --- a/src/detections/detection.rs +++ b/src/detections/detection.rs @@ -147,7 +147,8 @@ impl Detection { if !(stored_static.logon_summary_flag || stored_static.search_flag || stored_static.metrics_flag - || stored_static.computer_metrics_flag) + || stored_static.computer_metrics_flag + || stored_static.log_metrics_flag) { Detection::print_rule_load_info( &rulefile_loader.rulecounter, diff --git a/src/main.rs b/src/main.rs index 997c08882..7b98de3be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -395,7 +395,10 @@ impl App { } println!(); } - Action::EidMetrics(_) | Action::Search(_) => { + Action::EidMetrics(_) + | Action::ComputerMetrics(_) + | Action::LogMetrics(_) + | Action::Search(_) => { if let Some(path) = &stored_static.output_path { if !(stored_static.output_option.as_ref().unwrap().clobber) && utils::check_file_expect_not_exist( @@ -416,28 +419,6 @@ impl App { &stored_static.html_report_flag, ); } - Action::ComputerMetrics(_) => { - if let Some(path) = &stored_static.output_path { - if !(stored_static.output_option.as_ref().unwrap().clobber) - && utils::check_file_expect_not_exist( - path.as_path(), - format!( - " The file {} already exists. Please specify a different filename or add the -C, --clobber option to overwrite.\n", - path.as_os_str().to_str().unwrap() - ), - ) - { - return; - } - } - self.analysis_start(&target_extensions, &time_filter, stored_static); - output_saved_file( - &stored_static.output_path, - "Saved results", - &stored_static.html_report_flag, - ); - println!(); - } Action::PivotKeywordsList(_) => { load_pivot_keywords( utils::check_setting_path( @@ -1103,6 +1084,7 @@ impl App { || stored_static.logon_summary_flag || stored_static.search_flag || stored_static.computer_metrics_flag + || stored_static.log_metrics_flag || stored_static.output_option.as_ref().unwrap().no_wizard) { CHECKPOINT @@ -1471,7 +1453,8 @@ impl App { if !(stored_static.logon_summary_flag || stored_static.search_flag || stored_static.metrics_flag - || stored_static.computer_metrics_flag) + || stored_static.computer_metrics_flag + || stored_static.log_metrics_flag) { println!("Loading detection rules. Please wait."); } else if stored_static.logon_summary_flag { @@ -1482,6 +1465,8 @@ impl App { println!("Currently scanning for event ID metrics. Please wait."); } else if stored_static.computer_metrics_flag { println!("Currently scanning for computer metrics. Please wait."); + } else if stored_static.log_metrics_flag { + println!("Currently scanning for log metrics. Please wait."); } println!(); @@ -1489,7 +1474,8 @@ impl App { if !(stored_static.logon_summary_flag || stored_static.search_flag || stored_static.metrics_flag - || stored_static.computer_metrics_flag) + || stored_static.computer_metrics_flag + || stored_static.log_metrics_flag) { rule_files = detection::Detection::parse_rule_files( &level, @@ -1511,7 +1497,8 @@ impl App { let unused_rules_option = stored_static.logon_summary_flag || stored_static.search_flag || stored_static.computer_metrics_flag - || stored_static.metrics_flag; + || stored_static.metrics_flag + || stored_static.log_metrics_flag; if !unused_rules_option && rule_files.is_empty() { AlertMessage::alert( "No rules were loaded. Please download the latest rules with the update-rules command.\r\n", @@ -1647,12 +1634,15 @@ impl App { tl.search_dsp_msg(event_timeline_config, stored_static); } else if stored_static.computer_metrics_flag { tl.computer_metrics_dsp_msg(stored_static) + } else if stored_static.log_metrics_flag { + tl.log_metrics_dsp_msg(stored_static) } if !(stored_static.metrics_flag || stored_static.logon_summary_flag || stored_static.search_flag || stored_static.pivot_keyword_list_flag - || stored_static.computer_metrics_flag) + || stored_static.computer_metrics_flag + || stored_static.log_metrics_flag) { let mut log_records = detection.add_aggcondition_msges(&self.rt, stored_static); if stored_static.is_low_memory { @@ -2178,6 +2168,7 @@ impl App { // 以下のコマンドの際にはルールにかけない if !(stored_static.metrics_flag || stored_static.logon_summary_flag + || stored_static.log_metrics_flag || stored_static.search_flag) { // ruleファイルの検知 diff --git a/src/timeline/log_metrics.rs b/src/timeline/log_metrics.rs new file mode 100644 index 000000000..ff78aa9a2 --- /dev/null +++ b/src/timeline/log_metrics.rs @@ -0,0 +1,58 @@ +use crate::detections::configs::StoredStatic; +use crate::detections::detection::EvtxRecordInfo; +use crate::detections::utils; +use chrono::{DateTime, Utc}; +use std::collections::HashSet; + +#[derive(Default, Debug, Clone)] +pub struct LogMetrics { + pub filename: String, + pub computers: HashSet, + pub event_count: usize, + pub first_timestamp: Option>, + pub last_timestamp: Option>, + pub channels: HashSet, + pub providers: HashSet, +} + +impl LogMetrics { + pub fn new(filename: &str) -> Self { + Self { + filename: filename.to_string(), + ..Default::default() + } + } + pub fn update( + &mut self, + records: &[EvtxRecordInfo], + stored_static: &StoredStatic, + start_time: Option>, + end_time: Option>, + ) { + for record in records { + if let Some(computer) = + utils::get_event_value("Computer", &record.record, &stored_static.eventkey_alias) + { + self.computers + .insert(computer.to_string().trim_matches('"').to_string()); + } + if let Some(channel) = + utils::get_event_value("Channel", &record.record, &stored_static.eventkey_alias) + { + self.channels + .insert(channel.to_string().trim_matches('"').to_string()); + } + if let Some(provider) = utils::get_event_value( + "ProviderName", + &record.record, + &stored_static.eventkey_alias, + ) { + self.providers + .insert(provider.to_string().trim_matches('"').to_string()); + } + self.event_count += 1; + } + self.first_timestamp = start_time; + self.last_timestamp = end_time; + } +} diff --git a/src/timeline/metrics.rs b/src/timeline/metrics.rs index cccd8b0e3..05da4bd8c 100644 --- a/src/timeline/metrics.rs +++ b/src/timeline/metrics.rs @@ -6,10 +6,12 @@ use crate::detections::{ message::AlertMessage, utils, }; +use crate::timeline::log_metrics::LogMetrics; use crate::timeline::metrics::Channel::{RdsGtw, RdsLsm, Sec}; use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; use compact_str::CompactString; use hashbrown::{HashMap, HashSet}; +use std::path::Path; #[derive(Debug, Clone, Eq, Hash, PartialEq)] pub struct LoginEvent { @@ -32,6 +34,7 @@ pub struct EventMetrics { pub end_time: Option>, pub stats_list: HashMap<(CompactString, CompactString), usize>, pub stats_login_list: HashMap, + pub stats_logfile: Vec, } /** * Windows Event Logの統計情報を出力する @@ -52,6 +55,7 @@ impl EventMetrics { end_time, stats_list, stats_login_list, + stats_logfile: Vec::new(), } } @@ -78,12 +82,45 @@ impl EventMetrics { if !stored_static.logon_summary_flag { return; } - self.stats_time_cnt(records, stored_static); - self.stats_login_eventid(records, stored_static); } + pub fn logfile_stats_start( + &mut self, + records: &[EvtxRecordInfo], + stored_static: &StoredStatic, + ) { + if !stored_static.log_metrics_flag { + return; + } + + self.stats_time_cnt(records, stored_static); + let filename = Path::new(self.filepath.as_str()) + .file_name() + .unwrap_or_default() + .to_str() + .unwrap_or_default(); + if let Some(existing_lm) = self.stats_logfile.iter_mut().find(|lm| { + lm.filename == filename + && lm.computers.contains( + get_event_value_as_string( + "Computer", + &records[0].record, + &stored_static.eventkey_alias, + ) + .to_string() + .trim_matches('"'), + ) + }) { + existing_lm.update(records, stored_static, self.start_time, self.end_time); + } else { + let mut lm = LogMetrics::new(filename); + lm.update(records, stored_static, self.start_time, self.end_time); + self.stats_logfile.push(lm); + } + } + fn stats_time_cnt(&mut self, records: &[EvtxRecordInfo], stored_static: &StoredStatic) { if records.is_empty() { return; diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs index 33f7c1341..fae221ec4 100644 --- a/src/timeline/mod.rs +++ b/src/timeline/mod.rs @@ -1,4 +1,5 @@ pub mod computer_metrics; +mod log_metrics; pub mod metrics; pub mod search; pub mod timelines; diff --git a/src/timeline/timelines.rs b/src/timeline/timelines.rs index 7edcaf6c8..a8e1d05f7 100644 --- a/src/timeline/timelines.rs +++ b/src/timeline/timelines.rs @@ -1,8 +1,3 @@ -use std::cmp; -use std::fs::File; -use std::io::BufWriter; -use std::path::PathBuf; - use crate::detections::configs::{Action, EventInfoConfig, StoredStatic}; use crate::detections::detection::EvtxRecordInfo; use crate::detections::message::AlertMessage; @@ -19,6 +14,10 @@ use csv::WriterBuilder; use downcast_rs::__std::process; use nested::Nested; use num_format::{Locale, ToFormattedString}; +use std::cmp; +use std::fs::File; +use std::io::BufWriter; +use std::path::PathBuf; use termcolor::{BufferWriter, Color, ColorChoice}; use terminal_size::terminal_size; use terminal_size::Width; @@ -26,7 +25,9 @@ use terminal_size::Width; use super::computer_metrics; use super::metrics::EventMetrics; use super::search::EventSearch; +use crate::timeline::log_metrics::LogMetrics; use hashbrown::{HashMap, HashSet}; +use itertools::Itertools; #[derive(Debug, Clone)] pub struct Timeline { @@ -77,6 +78,8 @@ impl Timeline { ); } else if stored_static.logon_summary_flag { self.stats.logon_stats_start(records, stored_static); + } else if stored_static.log_metrics_flag { + self.stats.logfile_stats_start(records, stored_static); } else if stored_static.search_flag { self.event_search.search_start( records, @@ -275,14 +278,7 @@ impl Timeline { // イベント情報取得(eventtitleなど) // channel_eid_info.txtに登録あるものは情報設定 // 出力メッセージ1行作成 - let ch = stored_static.disp_abbr_generic.replace_all( - stored_static - .ch_config - .get(fmted_channel.as_str()) - .unwrap_or(fmted_channel) - .as_str(), - &stored_static.disp_abbr_general_values, - ); + let ch = replace_channel_abbr(stored_static, fmted_channel); if event_timeline_config .get_event_id(fmted_channel, event_id) @@ -496,6 +492,92 @@ impl Timeline { } } } + + pub fn log_metrics_dsp_msg(&mut self, stored_static: &StoredStatic) { + if let Action::LogMetrics(opt) = &stored_static.config.action.as_ref().unwrap() { + let log_metrics = &mut self.stats.stats_logfile; + log_metrics.sort_by(|a, b| a.event_count.cmp(&b.event_count).reverse()); + let header = vec![ + "Filename", + "Computers", + "Event Count", + "First Timestamp", + "Last Timestamp", + "Channels", + "Providers", + ]; + if let Some(path) = &opt.output { + let file = File::create(path).expect("Failed to create output file"); + let mut wrt = WriterBuilder::new().from_writer(file); + let _ = wrt.write_record(header); + for rec in &mut *log_metrics { + let _ = wrt.write_record(Self::create_record_array(rec, stored_static)); + } + } else { + let mut tb = Table::new(); + tb.load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_content_arrangement(ContentArrangement::DynamicFullWidth) + .set_header(&header); + for rec in &mut *log_metrics { + let r = Self::create_record_array(rec, stored_static); + tb.add_row(vec![ + Cell::new(r[0].to_string()), + Cell::new(r[1].to_string()), + Cell::new(r[2].to_string()), + Cell::new(r[3].to_string()), + Cell::new(r[4].to_string()), + Cell::new(r[5].to_string()), + Cell::new(r[6].to_string()), + ]); + } + println!("{tb}"); + } + } + } + + fn create_record_array(rec: &LogMetrics, stored_static: &StoredStatic) -> [String; 7] { + let ab_ch: Vec = rec + .channels + .iter() + .map(|ch| replace_channel_abbr(stored_static, &CompactString::from(ch))) + .collect(); + let ab_provider: Vec = rec + .providers + .iter() + .map(|ch| replace_provider_abbr(stored_static, &CompactString::from(ch))) + .collect(); + [ + rec.filename.to_string(), + rec.computers.iter().sorted().join(","), + rec.event_count.to_formatted_string(&Locale::en), + rec.first_timestamp.unwrap_or_default().to_string(), + rec.last_timestamp.unwrap_or_default().to_string(), + ab_ch.iter().sorted().join(","), + ab_provider.iter().sorted().join(","), + ] + } +} + +fn replace_channel_abbr(stored_static: &StoredStatic, fmted_channel: &CompactString) -> String { + stored_static.disp_abbr_generic.replace_all( + stored_static + .ch_config + .get(fmted_channel.as_str()) + .unwrap_or(fmted_channel) + .as_str(), + &stored_static.disp_abbr_general_values, + ) +} + +fn replace_provider_abbr(stored_static: &StoredStatic, fmted_provider: &CompactString) -> String { + stored_static.disp_abbr_generic.replace_all( + stored_static + .provider_abbr_config + .get(fmted_provider) + .unwrap_or(fmted_provider), + &stored_static.disp_abbr_general_values, + ) } #[cfg(test)]