Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 6 additions & 2 deletions etc/default/auth-monitor
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion etc/systemd/system/auth-monitor.service
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions src/auth_message_parser.rs
Original file line number Diff line number Diff line change
@@ -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<AuthFailedMessagePattern>,
}
Expand Down Expand Up @@ -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)]
Expand Down
38 changes: 37 additions & 1 deletion src/auth_message_parser_tests.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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);
}
}
47 changes: 35 additions & 12 deletions src/auth_monitor.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -22,48 +22,71 @@ impl AuthMonitor {
options: params.options,
file_watcher: AuthFileWatcher::new(&params.filepath)?,
auth_message_parser: AuthMessageParser::new(),
last_failed_auth: SystemTime::UNIX_EPOCH,
last_failed_auth_timestamp: 0,
});
}

pub fn update(&mut self, on_max_failed_attempts: impl FnOnce()) {
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(
&mut self,
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 {
Expand Down
6 changes: 4 additions & 2 deletions src/auth_monitor_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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 {
fn default() -> Self {
return AuthMonitorOptions {
max_failed_attempts: 5,
reset_after_seconds: 1800,
ignore_subsequent_fails_ms: 0,
};
}
}
Expand All @@ -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
);
}
}
11 changes: 11 additions & 0 deletions src/auth_monitor_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))?,
}
}
Expand Down Expand Up @@ -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(());
}
}
Expand Down
43 changes: 37 additions & 6 deletions src/auth_monitor_params_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthMonitorParams, Box<dyn Error>>;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading