diff --git a/Cargo.lock b/Cargo.lock index efa82fb..9e69efe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,7 +34,7 @@ dependencies = [ [[package]] name = "auth-monitor" -version = "1.1.0" +version = "1.2.0" dependencies = [ "chrono", "inotify", diff --git a/Cargo.toml b/Cargo.toml index 4fe2a8f..be933de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "auth-monitor" -version = "1.1.0" +version = "1.2.0" edition = "2021" [dependencies] +chrono = "0.4.40" inotify = "0.11.0" signal-hook = "0.3.17" [dev-dependencies] -chrono = "0.4.40" diff --git a/etc/default/auth-monitor b/etc/default/auth-monitor index aba678d..7e12d4a 100644 --- a/etc/default/auth-monitor +++ b/etc/default/auth-monitor @@ -1,11 +1,15 @@ -# The maximum number of failed authentication attempts before the system shutdown. +# The maximum number of failed authentication attempts before the system shuts down. # Default: 5 MAX_FAILED_ATTEMPTS=5 -# The authentication failure counter will reset after the specified seconds. +# The authentication failure counter will reset after the specified number of seconds. # Default: 1800 RESET_AFTER_SECONDS=1800 +# The subsequent authentication failures will be ignored for the specified milliseconds. +# Default: 0 +IGNORE_SUBSEQUENT_FAILS_MS=100 + # The path to the file were authentication logs are stored. # Default: /var/log/auth.log LOG_FILE=/var/log/auth.log diff --git a/etc/systemd/system/auth-monitor.service b/etc/systemd/system/auth-monitor.service index 3a4e2a6..e2624d4 100644 --- a/etc/systemd/system/auth-monitor.service +++ b/etc/systemd/system/auth-monitor.service @@ -3,7 +3,10 @@ Description=AuthMonitor [Service] EnvironmentFile=/etc/default/auth-monitor -ExecStart=/usr/local/bin/auth-monitor ${LOG_FILE} --max-failed-attempts=${MAX_FAILED_ATTEMPTS} --reset-after-seconds=${RESET_AFTER_SECONDS} +ExecStart=/usr/local/bin/auth-monitor "${LOG_FILE}" \ + --max-failed-attempts=${MAX_FAILED_ATTEMPTS} \ + --reset-after-seconds=${RESET_AFTER_SECONDS} \ + --ignore-subsequent-fails-ms=${IGNORE_SUBSEQUENT_FAILS_MS} Restart=always User=auth-monitor diff --git a/src/auth_message_parser.rs b/src/auth_message_parser.rs index 0d6039e..3eea26e 100644 --- a/src/auth_message_parser.rs +++ b/src/auth_message_parser.rs @@ -1,3 +1,7 @@ +use chrono::DateTime; + +pub const DATE_FORMAT_ISO_8601: &str = "%Y-%m-%dT%H:%M:%S.%6f%:z"; + pub struct AuthMessageParser { patterns: Vec, } @@ -36,6 +40,14 @@ impl AuthMessageParser { } return false; } + + pub fn get_message_timestamp_millis(&self, message: &str) -> i64 { + let date_time_str = message.get(0..32).unwrap_or(""); + return match DateTime::parse_from_str(date_time_str, DATE_FORMAT_ISO_8601) { + Ok(date_time) => date_time.timestamp_millis(), + Err(_) => 0, + }; + } } #[cfg(test)] diff --git a/src/auth_message_parser_tests.rs b/src/auth_message_parser_tests.rs index 614780f..c985e4d 100644 --- a/src/auth_message_parser_tests.rs +++ b/src/auth_message_parser_tests.rs @@ -1,4 +1,7 @@ -use crate::auth_message_parser::AuthMessageParser; +use chrono::{Duration, Local}; +use std::ops::Sub; + +use crate::auth_message_parser::{AuthMessageParser, DATE_FORMAT_ISO_8601}; use crate::test_utils::test_file::AUTH_FAILED_TEST_MESSAGES; #[test] @@ -55,3 +58,36 @@ fn when_message_is_not_auth_failed_message_then_returns_false() { assert!(!parser.is_auth_failed_message(message)); } } + +#[test] +fn when_parsing_message_with_incorrect_date_time_format_then_return_0() { + let messages = "2024-02-10T14:26:03.323862+01:0 workstation systemd-logind[2089]: The system will power off now! +2024-02-10T14:26:03.341715 workstation systemd-logind[2089]: System is powering down. +2024-02-10T14:34:24.37471+01:00 workstation sudo: pam_unix(sudo:session): session closed for user root +workstation sudo: pam_unix(sudo:session): session closed for user root"; + let parser = AuthMessageParser::new(); + for message in messages.split('\n') { + let message_timestamp = parser.get_message_timestamp_millis(message); + assert_eq!(message_timestamp, 0); + } +} + +#[test] +fn when_parsing_message_with_correct_date_time_format_then_return_timestamp_in_millis() { + let now = Local::now(); + let date_times = [ + now, + now.sub(Duration::milliseconds(123)), + now.sub(Duration::seconds(1234)), + now.sub(Duration::minutes(1234)), + now.sub(Duration::hours(4)), + ]; + let parser = AuthMessageParser::new(); + for date_time in date_times { + let formatted_date_time = date_time.format(DATE_FORMAT_ISO_8601); + let message = format!("{} {}", formatted_date_time, AUTH_FAILED_TEST_MESSAGES[0]); + let message_timestamp = parser.get_message_timestamp_millis(&message); + let expected_timestamp = date_time.timestamp_millis(); + assert_eq!(message_timestamp, expected_timestamp); + } +} diff --git a/src/auth_monitor.rs b/src/auth_monitor.rs index 5117e8b..48e3540 100644 --- a/src/auth_monitor.rs +++ b/src/auth_monitor.rs @@ -1,5 +1,5 @@ +use chrono::Local; use std::error::Error; -use std::time::{Duration, SystemTime}; use crate::auth_file_watcher::AuthFileWatcher; use crate::auth_message_parser::AuthMessageParser; @@ -11,7 +11,7 @@ pub struct AuthMonitor { options: AuthMonitorOptions, file_watcher: AuthFileWatcher, auth_message_parser: AuthMessageParser, - last_failed_auth: SystemTime, + last_failed_auth_timestamp: i64, } impl AuthMonitor { @@ -22,7 +22,7 @@ impl AuthMonitor { options: params.options, file_watcher: AuthFileWatcher::new(¶ms.filepath)?, auth_message_parser: AuthMessageParser::new(), - last_failed_auth: SystemTime::UNIX_EPOCH, + last_failed_auth_timestamp: 0, }); } @@ -30,32 +30,56 @@ impl AuthMonitor { if self.should_reset_failed_attempts() { self.reset_failed_attempts(); } + let mut failed_attempts = 0; + self.file_watcher.update(|line| { - if self.auth_message_parser.is_auth_failed_message(line) { - failed_attempts += 1; + if !self.auth_message_parser.is_auth_failed_message(line) { + return; + } + print!("Auth failed message: {}", line); + let mut auth_failed_timestamp = + self.auth_message_parser.get_message_timestamp_millis(line); + if auth_failed_timestamp == 0 { + auth_failed_timestamp = Local::now().timestamp_millis(); + } + if self.options.ignore_subsequent_fails_ms > 0 { + let millis_since_last_failed_auth = + auth_failed_timestamp - self.last_failed_auth_timestamp; + if millis_since_last_failed_auth <= self.options.ignore_subsequent_fails_ms as i64 { + println!( + "Auth fail ignored ({} ms since last)", + millis_since_last_failed_auth + ); + return; + } } + self.last_failed_auth_timestamp = auth_failed_timestamp; + failed_attempts += 1; }); + if failed_attempts > 0 { self.increase_failed_attempts(failed_attempts, on_max_failed_attempts); } } fn should_reset_failed_attempts(&self) -> bool { + if self.last_failed_auth_timestamp == 0 { + return false; + } if self.failed_attempts <= 0 || self.failed_attempts >= self.options.max_failed_attempts { return false; } - let seconds_from_last_error = SystemTime::now() - .duration_since(self.last_failed_auth) - .unwrap_or(Duration::ZERO) - .as_secs(); - return seconds_from_last_error > self.options.reset_after_seconds as u64; + let millis_since_last_auth_fail = + Local::now().timestamp_millis() - self.last_failed_auth_timestamp; + let reset_after_millis = self.options.reset_after_seconds as i64 * 1000; + return millis_since_last_auth_fail > reset_after_millis; } fn reset_failed_attempts(&mut self) { println!("Resetting failed attempts"); self.failed_attempts = 0; - self.last_failed_auth = SystemTime::now(); + self.last_failed_auth_timestamp = 0; } fn increase_failed_attempts( @@ -63,7 +87,6 @@ impl AuthMonitor { failed_attempts: i32, on_max_failed_attempts: impl FnOnce(), ) { - self.last_failed_auth = SystemTime::now(); self.failed_attempts += failed_attempts; println!("Authentication failed {} time(s)", self.failed_attempts); if self.failed_attempts >= self.options.max_failed_attempts { diff --git a/src/auth_monitor_options.rs b/src/auth_monitor_options.rs index 795fec7..68c5113 100644 --- a/src/auth_monitor_options.rs +++ b/src/auth_monitor_options.rs @@ -4,6 +4,7 @@ use std::fmt::{Display, Formatter}; pub struct AuthMonitorOptions { pub max_failed_attempts: i32, pub reset_after_seconds: i32, + pub ignore_subsequent_fails_ms: i32, } impl Default for AuthMonitorOptions { @@ -11,6 +12,7 @@ impl Default for AuthMonitorOptions { return AuthMonitorOptions { max_failed_attempts: 5, reset_after_seconds: 1800, + ignore_subsequent_fails_ms: 0, }; } } @@ -19,8 +21,8 @@ impl Display for AuthMonitorOptions { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { return write!( formatter, - "max-failed-attempts={}, reset-after-seconds={}", - self.max_failed_attempts, self.reset_after_seconds + "max-failed-attempts={}, reset-after-seconds={}, ignore-subsequent-fails-ms={}", + self.max_failed_attempts, self.reset_after_seconds, self.ignore_subsequent_fails_ms ); } } diff --git a/src/auth_monitor_params.rs b/src/auth_monitor_params.rs index 4c7dfe9..92f06db 100644 --- a/src/auth_monitor_params.rs +++ b/src/auth_monitor_params.rs @@ -12,6 +12,7 @@ const OPTION_VALUE_SEPARATOR_LENGTH: usize = 1; const MAX_FAILED_ATTEMPTS_OPTION: &str = "max-failed-attempts"; const RESET_AFTER_SECONDS_OPTION: &str = "reset-after-seconds"; +const IGNORE_SUBSEQUENT_FAILS_MS_OPTION: &str = "ignore-subsequent-fails-ms"; pub struct AuthMonitorParams { pub filepath: String, @@ -47,6 +48,10 @@ impl AuthMonitorParams { params.options.reset_after_seconds = Self::parse_option_value(option_name, option_value)?; } + IGNORE_SUBSEQUENT_FAILS_MS_OPTION => { + params.options.ignore_subsequent_fails_ms = + Self::parse_option_value(option_name, option_value)?; + } _ => Err(format!("Unknown option {}", argument))?, } } @@ -92,6 +97,12 @@ impl AuthMonitorParams { RESET_AFTER_SECONDS_OPTION ))?; } + if self.options.ignore_subsequent_fails_ms < 0 { + return Err(format!( + "{} must be greater than or equal 0", + IGNORE_SUBSEQUENT_FAILS_MS_OPTION + ))?; + } return Ok(()); } } diff --git a/src/auth_monitor_params_tests.rs b/src/auth_monitor_params_tests.rs index f642cdd..db37a1c 100644 --- a/src/auth_monitor_params_tests.rs +++ b/src/auth_monitor_params_tests.rs @@ -3,11 +3,21 @@ use std::error::Error; use crate::assert_error; use crate::auth_monitor_options::AuthMonitorOptions; use crate::auth_monitor_params::{ - AuthMonitorParams, MAX_FAILED_ATTEMPTS_OPTION, RESET_AFTER_SECONDS_OPTION, + AuthMonitorParams, IGNORE_SUBSEQUENT_FAILS_MS_OPTION, MAX_FAILED_ATTEMPTS_OPTION, + RESET_AFTER_SECONDS_OPTION, }; const FILEPATH: &str = "/var/log/auth.log"; -const ALL_OPTIONS: [&str; 2] = [MAX_FAILED_ATTEMPTS_OPTION, RESET_AFTER_SECONDS_OPTION]; +const ALL_OPTIONS: [&str; 3] = [ + MAX_FAILED_ATTEMPTS_OPTION, + RESET_AFTER_SECONDS_OPTION, + IGNORE_SUBSEQUENT_FAILS_MS_OPTION, +]; + +const ZERO_VALUE_ALLOWED_OPTIONS: [&str; 1] = [IGNORE_SUBSEQUENT_FAILS_MS_OPTION]; + +const ZERO_VALUE_NOT_ALLOWED_OPTIONS: [&str; 2] = + [MAX_FAILED_ATTEMPTS_OPTION, RESET_AFTER_SECONDS_OPTION]; type AuthMonitorResult = Result>; @@ -94,11 +104,16 @@ fn when_parsing_filepath_with_one_option_with_correct_value_then_return_params_w true => value, false => default_params.options.reset_after_seconds, }; + let ignore_subsequent_fails_ms = match option == IGNORE_SUBSEQUENT_FAILS_MS_OPTION { + true => value, + false => default_params.options.ignore_subsequent_fails_ms, + }; let expected = AuthMonitorParams { filepath: String::from(FILEPATH), options: AuthMonitorOptions { max_failed_attempts, reset_after_seconds, + ignore_subsequent_fails_ms, }, }; expect_equals(AuthMonitorParams::from_arguments(&arguments), &expected); @@ -141,32 +156,48 @@ fn when_parsing_option_with_no_value_then_return_no_value_error() { } #[test] -fn when_parsing_option_with_value_less_than_0_then_invalid_value_error() { - let invalid_values = [0, -1, -1024, i32::MIN]; - for option in ALL_OPTIONS { - for value in invalid_values { +fn when_parsing_option_with_out_of_range_value_then_return_value_out_of_range_error() { + let zero_not_allowed_invalid_values = [0, -1, -1024, i32::MIN]; + for option in ZERO_VALUE_NOT_ALLOWED_OPTIONS { + for value in zero_not_allowed_invalid_values { let option_argument = format!("--{}={}", option, value); let arguments = [String::from(FILEPATH), option_argument]; let expected = format!("{} must be greater than 0", option); assert_error!(AuthMonitorParams::from_arguments(&arguments), expected); } } + + let zero_allowed_invalid_values = [-1, -1024, i32::MIN]; + for option in ZERO_VALUE_ALLOWED_OPTIONS { + for value in zero_allowed_invalid_values { + let option_argument = format!("--{}={}", option, value); + let arguments = [String::from(FILEPATH), option_argument]; + let expected = format!("{} must be greater than or equal 0", option); + assert_error!(AuthMonitorParams::from_arguments(&arguments), expected); + } + } } #[test] fn when_parsing_filename_and_multiple_options_then_return_params_with_parsed_values() { let max_failed_attempts = 10; let reset_after_seconds = 3600; + let ignore_subsequent_fails_ms = 350; let arguments = [ String::from(FILEPATH), format!("--{}={}", MAX_FAILED_ATTEMPTS_OPTION, max_failed_attempts), format!("--{}={}", RESET_AFTER_SECONDS_OPTION, reset_after_seconds), + format!( + "--{}={}", + IGNORE_SUBSEQUENT_FAILS_MS_OPTION, ignore_subsequent_fails_ms + ), ]; let expected = AuthMonitorParams { filepath: String::from(FILEPATH), options: AuthMonitorOptions { max_failed_attempts, reset_after_seconds, + ignore_subsequent_fails_ms, }, }; expect_equals(AuthMonitorParams::from_arguments(&arguments), &expected); diff --git a/src/auth_monitor_tests.rs b/src/auth_monitor_tests.rs index 7634682..9142fbb 100644 --- a/src/auth_monitor_tests.rs +++ b/src/auth_monitor_tests.rs @@ -133,6 +133,48 @@ pub fn when_reset_time_has_passed_then_reset_failed_attempt_counter() { test.expect_update_callback_is_called_once(); } +#[test] +pub fn when_ignore_subsequent_fails_duration_has_not_elapsed_then_update_callback_is_not_called() { + let mut file = TestFile::not_empty(); + let options = AuthMonitorOptions { + ignore_subsequent_fails_ms: 10, + ..AuthMonitorOptions::default() + }; + let mut test = AuthMonitorTest::new(file.path(), options); + test.expect_no_update_callback_call(); + + let max_failed_attempts = options.max_failed_attempts as usize; + + for i in 0usize..max_failed_attempts { + file.write_auth_failed_message(i); + test.expect_no_update_callback_call(); + } + + test.expect_no_update_callback_call(); +} + +#[test] +pub fn when_ignore_subsequent_fails_duration_has_elapsed_then_update_callback_is_called() { + let mut file = TestFile::not_empty(); + let options = AuthMonitorOptions { + ignore_subsequent_fails_ms: 10, + ..AuthMonitorOptions::default() + }; + let mut test = AuthMonitorTest::new(file.path(), options); + test.expect_no_update_callback_call(); + + let max_failed_attempts = options.max_failed_attempts as usize; + let sleep_duration = Duration::from_millis((options.ignore_subsequent_fails_ms + 1) as u64); + + for i in 0usize..max_failed_attempts { + file.write_auth_failed_message(i); + println!("Sleeping for {} ms", sleep_duration.as_millis()); + sleep(sleep_duration); + } + + test.expect_update_callback_is_called_once(); +} + #[test] fn when_file_is_deleted_and_new_one_is_created_then_changes_are_still_monitored() { let mut file = TestFile::not_empty(); diff --git a/src/test_utils/test_file.rs b/src/test_utils/test_file.rs index 87c463c..cd08241 100644 --- a/src/test_utils/test_file.rs +++ b/src/test_utils/test_file.rs @@ -1,9 +1,10 @@ +use chrono::Local; use std::env::temp_dir; use std::fs::{remove_file, rename, File}; use std::io::Write; use std::sync::atomic::{AtomicUsize, Ordering}; -use chrono::Local; +use crate::auth_message_parser::DATE_FORMAT_ISO_8601; pub const AUTH_FAILED_TEST_MESSAGES: [&str; 6] = [ "workstation sudo: pam_unix(sudo:auth): authentication failure; logname=john uid=1000 euid=0 tty=/dev/pts/7 ruser=john rhost= user=john", @@ -86,7 +87,7 @@ impl TestFile { } fn write_log_message(&mut self, message: &str) { - let date_time = Local::now().format("%+"); + let date_time = Local::now().format(DATE_FORMAT_ISO_8601); let line = format!("{} {}\n", date_time, message); self.write(&line); }