From 698ff49858c217d8cb5f158b52a85bc3e38569ee Mon Sep 17 00:00:00 2001 From: stacknil Date: Wed, 27 May 2026 00:48:11 +0800 Subject: [PATCH] feat(parser): normalize illegal-user ssh evidence --- ...r_fixture_matrix_journalctl_short_full.log | 2 + assets/parser_fixture_matrix_syslog.log | 2 + docs/parser-contract.md | 2 +- src/parser.cpp | 46 +++++---- tests/test_parser.cpp | 98 +++++++++++++++++-- 5 files changed, 121 insertions(+), 29 deletions(-) diff --git a/assets/parser_fixture_matrix_journalctl_short_full.log b/assets/parser_fixture_matrix_journalctl_short_full.log index 7f22377..d7193ac 100644 --- a/assets/parser_fixture_matrix_journalctl_short_full.log +++ b/assets/parser_fixture_matrix_journalctl_short_full.log @@ -15,6 +15,8 @@ Tue 2026-03-10 09:03:39 UTC example-host sshd[3017]: Failed keyboard-interactive Tue 2026-03-10 09:03:39 UTC example-host sshd[3018]: maximum authentication attempts exceeded for frank from 203.0.113.45 port 52007 ssh2 [preauth] Tue 2026-03-10 09:03:39 UTC example-host sshd[3019]: Failed keyboard-interactive/pam for invalid user svc-keyboard from 203.0.113.46 port 52008 ssh2 Tue 2026-03-10 09:03:39 UTC example-host sshd[3020]: maximum authentication attempts exceeded for invalid user svc-maxauth from 203.0.113.47 port 52009 ssh2 [preauth] +Tue 2026-03-10 09:03:39 UTC example-host sshd[3021]: Failed password for illegal user legacy-admin from 203.0.113.48 port 52017 ssh2 +Tue 2026-03-10 09:03:39 UTC example-host sshd[3022]: Illegal user legacy-backup from 203.0.113.49 port 52018 Tue 2026-03-10 09:03:40 UTC example-host sshd[3003]: Connection closed by user alice 203.0.113.50 port 52010 [preauth] Tue 2026-03-10 09:04:05 UTC example-host sshd[3004]: Connection closed by authenticating user carol 203.0.113.51 port 52011 [preauth] Tue 2026-03-10 09:04:28 UTC example-host sshd[3005]: Connection closed by invalid user deploy 203.0.113.52 port 52012 [preauth] diff --git a/assets/parser_fixture_matrix_syslog.log b/assets/parser_fixture_matrix_syslog.log index 953ef58..2707bd7 100644 --- a/assets/parser_fixture_matrix_syslog.log +++ b/assets/parser_fixture_matrix_syslog.log @@ -15,6 +15,8 @@ Mar 10 09:03:39 example-host sshd[2017]: Failed keyboard-interactive/pam for eve Mar 10 09:03:39 example-host sshd[2018]: maximum authentication attempts exceeded for frank from 203.0.113.45 port 52007 ssh2 [preauth] Mar 10 09:03:39 example-host sshd[2019]: Failed keyboard-interactive/pam for invalid user svc-keyboard from 203.0.113.46 port 52008 ssh2 Mar 10 09:03:39 example-host sshd[2020]: maximum authentication attempts exceeded for invalid user svc-maxauth from 203.0.113.47 port 52009 ssh2 [preauth] +Mar 10 09:03:39 example-host sshd[2021]: Failed password for illegal user legacy-admin from 203.0.113.48 port 52017 ssh2 +Mar 10 09:03:39 example-host sshd[2022]: Illegal user legacy-backup from 203.0.113.49 port 52018 Mar 10 09:03:40 example-host sshd[2003]: Connection closed by user alice 203.0.113.50 port 52010 [preauth] Mar 10 09:04:05 example-host sshd[2004]: Connection closed by authenticating user carol 203.0.113.51 port 52011 [preauth] Mar 10 09:04:28 example-host sshd[2005]: Connection closed by invalid user deploy 203.0.113.52 port 52012 [preauth] diff --git a/docs/parser-contract.md b/docs/parser-contract.md index 577f034..a969f4e 100644 --- a/docs/parser-contract.md +++ b/docs/parser-contract.md @@ -26,7 +26,7 @@ The parser currently recognizes common authentication evidence from: - selected `pam_faillock(...)` variants - selected `pam_sss(...)` variants -Recognized SSH failure families include failed password, invalid user, failed publickey, failed keyboard-interactive/pam, and maximum-authentication-attempts-exceeded lines. Invalid-user variants of keyboard-interactive and maximum-authentication-attempts-exceeded lines are normalized into `ssh_invalid_user` events. Recognized SSH failures can become detection signals through the configured signal mapping. +Recognized SSH failure families include failed password, invalid user, illegal user, failed publickey, failed keyboard-interactive/pam, and maximum-authentication-attempts-exceeded lines. `illegal user` is treated as an OpenSSH wording variant of `invalid user`. Invalid or illegal-user variants of keyboard-interactive and maximum-authentication-attempts-exceeded lines are normalized into `ssh_invalid_user` events. Recognized SSH failures can become detection signals through the configured signal mapping. Recognized success or audit families include accepted password, accepted publickey, accepted keyboard-interactive/pam, sudo command audit lines, sudo password failures, sudoers policy denials, su success/failure audit lines, and selected PAM session/auth lines. diff --git a/src/parser.cpp b/src/parser.cpp index 2a974cd..b0b3861 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -285,6 +285,23 @@ std::string sanitize_pattern_label(std::string_view value) { return normalized.empty() ? "unknown_pattern" : normalized; } +bool consume_invalid_or_illegal_user_prefix(std::string_view& remaining) { + static constexpr std::string_view invalid_user_prefix = "invalid user "; + static constexpr std::string_view illegal_user_prefix = "illegal user "; + + if (remaining.starts_with(invalid_user_prefix)) { + remaining.remove_prefix(invalid_user_prefix.size()); + return true; + } + + if (remaining.starts_with(illegal_user_prefix)) { + remaining.remove_prefix(illegal_user_prefix.size()); + return true; + } + + return false; +} + bool parse_ssh_failed_message(std::string_view message, Event& event) { static constexpr std::string_view failed_prefix = "Failed password for "; if (!message.starts_with(failed_prefix)) { @@ -292,11 +309,7 @@ bool parse_ssh_failed_message(std::string_view message, Event& event) { } auto remaining = message.substr(failed_prefix.size()); - bool invalid_user = false; - if (remaining.starts_with("invalid user ")) { - invalid_user = true; - remaining.remove_prefix(std::string_view{"invalid user "}.size()); - } + const bool invalid_user = consume_invalid_or_illegal_user_prefix(remaining); const auto username = consume_token(remaining); if (username.empty()) { @@ -370,9 +383,7 @@ bool parse_ssh_failed_publickey_message(std::string_view message, Event& event) } auto remaining = message.substr(publickey_prefix.size()); - if (remaining.starts_with("invalid user ")) { - remaining.remove_prefix(std::string_view{"invalid user "}.size()); - } + consume_invalid_or_illegal_user_prefix(remaining); const auto username = consume_token(remaining); if (username.empty()) { @@ -392,11 +403,7 @@ bool parse_ssh_failed_keyboard_interactive_message(std::string_view message, Eve } auto remaining = message.substr(keyboard_prefix.size()); - bool invalid_user = false; - if (remaining.starts_with("invalid user ")) { - invalid_user = true; - remaining.remove_prefix(std::string_view{"invalid user "}.size()); - } + const bool invalid_user = consume_invalid_or_illegal_user_prefix(remaining); const auto username = consume_token(remaining); if (username.empty()) { @@ -416,11 +423,7 @@ bool parse_ssh_max_auth_tries_message(std::string_view message, Event& event) { } auto remaining = message.substr(max_auth_prefix.size()); - bool invalid_user = false; - if (remaining.starts_with("invalid user ")) { - invalid_user = true; - remaining.remove_prefix(std::string_view{"invalid user "}.size()); - } + const bool invalid_user = consume_invalid_or_illegal_user_prefix(remaining); const auto username = consume_token(remaining); if (username.empty()) { @@ -435,11 +438,14 @@ bool parse_ssh_max_auth_tries_message(std::string_view message, Event& event) { bool parse_ssh_invalid_user_message(std::string_view message, Event& event) { static constexpr std::string_view invalid_user_prefix = "Invalid user "; - if (!message.starts_with(invalid_user_prefix)) { + static constexpr std::string_view illegal_user_prefix = "Illegal user "; + if (!message.starts_with(invalid_user_prefix) && !message.starts_with(illegal_user_prefix)) { return false; } - auto remaining = message.substr(invalid_user_prefix.size()); + auto remaining = message.starts_with(invalid_user_prefix) + ? message.substr(invalid_user_prefix.size()) + : message.substr(illegal_user_prefix.size()); const auto username = consume_token(remaining); if (username.empty()) { return false; diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index f3e81bc..4f42267 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -78,6 +78,32 @@ void test_invalid_user_failure() { "expected explicit syslog year injection"); } +void test_illegal_user_failure_is_normalized_as_invalid_user() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:11:23 example-host sshd[1235]: Failed password for illegal user legacy-admin from 203.0.113.11 port 51023 ssh2", + 1); + + expect(event.has_value(), "expected illegal-user failed-password event"); + expect(event->username == "legacy-admin", "expected illegal-user failed-password username"); + expect(event->source_ip == "203.0.113.11", "expected illegal-user failed-password source ip"); + expect(event->event_type == loglens::EventType::SshInvalidUser, + "expected illegal-user failed-password to normalize to invalid-user type"); +} + +void test_illegal_user_message_is_normalized_as_invalid_user() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:11:24 example-host sshd[1236]: Illegal user legacy-backup from 203.0.113.12 port 51024", + 1); + + expect(event.has_value(), "expected direct illegal-user event"); + expect(event->username == "legacy-backup", "expected direct illegal-user username"); + expect(event->source_ip == "203.0.113.12", "expected direct illegal-user source ip"); + expect(event->event_type == loglens::EventType::SshInvalidUser, + "expected direct illegal-user to normalize to invalid-user type"); +} + void test_standard_failure() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( @@ -200,6 +226,19 @@ void test_failed_publickey_event() { expect(event->event_type == loglens::EventType::SshFailedPublicKey, "expected ssh publickey type"); } +void test_failed_publickey_illegal_user_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:27:11 example-host sshd[1248]: Failed publickey for illegal user svc-legacy from 203.0.113.81 port 51245 ssh2", + 5); + + expect(event.has_value(), "expected failed publickey illegal-user event"); + expect(event->username == "svc-legacy", "expected parsed illegal-user publickey username"); + expect(event->source_ip == "203.0.113.81", "expected parsed illegal-user publickey source ip"); + expect(event->event_type == loglens::EventType::SshFailedPublicKey, + "expected illegal-user publickey to keep publickey failure type"); +} + void test_failed_keyboard_interactive_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( @@ -226,6 +265,19 @@ void test_failed_keyboard_interactive_invalid_user_event() { "expected keyboard-interactive invalid-user type"); } +void test_failed_keyboard_interactive_illegal_user_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:27:20 example-host sshd[1249]: Failed keyboard-interactive/pam for illegal user svc-keyboard-legacy from 203.0.113.82 port 51246 ssh2", + 5); + + expect(event.has_value(), "expected failed keyboard-interactive illegal-user event"); + expect(event->username == "svc-keyboard-legacy", "expected parsed keyboard-interactive illegal username"); + expect(event->source_ip == "203.0.113.82", "expected parsed keyboard-interactive illegal source ip"); + expect(event->event_type == loglens::EventType::SshInvalidUser, + "expected keyboard-interactive illegal-user type"); +} + void test_max_auth_tries_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( @@ -252,6 +304,19 @@ void test_max_auth_tries_invalid_user_event() { "expected max-auth-tries invalid-user type"); } +void test_max_auth_tries_illegal_user_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:27:27 example-host sshd[1250]: maximum authentication attempts exceeded for illegal user svc-maxauth-legacy from 203.0.113.83 port 51247 ssh2 [preauth]", + 5); + + expect(event.has_value(), "expected max-auth-tries illegal-user event"); + expect(event->username == "svc-maxauth-legacy", "expected parsed max-auth-tries illegal username"); + expect(event->source_ip == "203.0.113.83", "expected parsed max-auth-tries illegal source ip"); + expect(event->event_type == loglens::EventType::SshInvalidUser, + "expected max-auth-tries illegal-user type"); +} + void test_pam_auth_failure_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( @@ -543,12 +608,12 @@ void test_syslog_fixture_matrix_file() { const auto parser = make_syslog_parser(); const auto result = parser.parse_file(asset_path("parser_fixture_matrix_syslog.log")); - expect(result.events.size() == 17, "expected seventeen recognized syslog fixture events"); + expect(result.events.size() == 19, "expected nineteen recognized syslog fixture events"); expect(result.warnings.size() == 8, "expected eight syslog fixture warnings"); - expect(result.quality.total_lines == 25, "expected twenty-five syslog fixture lines"); - expect(result.quality.parsed_lines == 17, "expected seventeen parsed syslog fixture lines"); + expect(result.quality.total_lines == 27, "expected twenty-seven syslog fixture lines"); + expect(result.quality.parsed_lines == 19, "expected nineteen parsed syslog fixture lines"); expect(result.quality.unparsed_lines == 8, "expected eight unparsed syslog fixture lines"); - expect_close(result.quality.parse_success_rate, 17.0 / 25.0, 1e-9, "expected syslog fixture parse success rate"); + expect_close(result.quality.parse_success_rate, 19.0 / 27.0, 1e-9, "expected syslog fixture parse success rate"); expect(result.events[0].event_type == loglens::EventType::SshInvalidUser, "expected invalid-user failed password"); expect(result.events[1].event_type == loglens::EventType::SshFailedPublicKey, "expected failed publickey variant"); @@ -589,6 +654,12 @@ void test_syslog_fixture_matrix_file() { expect(result.events[16].event_type == loglens::EventType::SshInvalidUser, "expected max-auth-tries invalid-user variant"); expect(result.events[16].username == "svc-maxauth", "expected max-auth-tries invalid username"); + expect(result.events[17].event_type == loglens::EventType::SshInvalidUser, + "expected failed-password illegal-user variant"); + expect(result.events[17].username == "legacy-admin", "expected failed-password illegal username"); + expect(result.events[18].event_type == loglens::EventType::SshInvalidUser, + "expected direct illegal-user variant"); + expect(result.events[18].username == "legacy-backup", "expected direct illegal username"); expect(result.quality.top_unknown_patterns.size() == 4, "expected four unknown syslog buckets"); expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", @@ -611,12 +682,12 @@ void test_journalctl_fixture_matrix_file() { std::nullopt}); const auto result = parser.parse_file(asset_path("parser_fixture_matrix_journalctl_short_full.log")); - expect(result.events.size() == 17, "expected seventeen recognized journalctl fixture events"); + expect(result.events.size() == 19, "expected nineteen recognized journalctl fixture events"); expect(result.warnings.size() == 8, "expected eight journalctl fixture warnings"); - expect(result.quality.total_lines == 25, "expected twenty-five journalctl fixture lines"); - expect(result.quality.parsed_lines == 17, "expected seventeen parsed journalctl fixture lines"); + expect(result.quality.total_lines == 27, "expected twenty-seven journalctl fixture lines"); + expect(result.quality.parsed_lines == 19, "expected nineteen parsed journalctl fixture lines"); expect(result.quality.unparsed_lines == 8, "expected eight unparsed journalctl fixture lines"); - expect_close(result.quality.parse_success_rate, 17.0 / 25.0, 1e-9, "expected journalctl fixture parse success rate"); + expect_close(result.quality.parse_success_rate, 19.0 / 27.0, 1e-9, "expected journalctl fixture parse success rate"); expect(result.events[0].event_type == loglens::EventType::SshInvalidUser, "expected journalctl invalid-user failed password"); expect(result.events[1].event_type == loglens::EventType::SshFailedPublicKey, "expected journalctl failed publickey variant"); @@ -647,6 +718,12 @@ void test_journalctl_fixture_matrix_file() { expect(result.events[16].event_type == loglens::EventType::SshInvalidUser, "expected journalctl max-auth-tries invalid-user variant"); expect(result.events[16].username == "svc-maxauth", "expected journalctl max-auth-tries invalid username"); + expect(result.events[17].event_type == loglens::EventType::SshInvalidUser, + "expected journalctl failed-password illegal-user variant"); + expect(result.events[17].username == "legacy-admin", "expected journalctl failed-password illegal username"); + expect(result.events[18].event_type == loglens::EventType::SshInvalidUser, + "expected journalctl direct illegal-user variant"); + expect(result.events[18].username == "legacy-backup", "expected journalctl direct illegal username"); expect(result.quality.top_unknown_patterns.size() == 4, "expected four unknown journalctl buckets"); expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", @@ -667,6 +744,8 @@ void test_journalctl_fixture_matrix_file() { int main() { test_invalid_user_failure(); + test_illegal_user_failure_is_normalized_as_invalid_user(); + test_illegal_user_message_is_normalized_as_invalid_user(); test_standard_failure(); test_success_event(); test_accepted_publickey_success_event(); @@ -677,10 +756,13 @@ int main() { test_su_auth_failure_event(); test_su_success_event(); test_failed_publickey_event(); + test_failed_publickey_illegal_user_event(); test_failed_keyboard_interactive_event(); test_failed_keyboard_interactive_invalid_user_event(); + test_failed_keyboard_interactive_illegal_user_event(); test_max_auth_tries_event(); test_max_auth_tries_invalid_user_event(); + test_max_auth_tries_illegal_user_event(); test_pam_auth_failure_event(); test_pam_sss_received_failure_event(); test_session_opened_event();