From 4db58fff7487b6c2ff0e4d01f6a18dc705113a00 Mon Sep 17 00:00:00 2001 From: stacknil Date: Sun, 7 Jun 2026 15:36:01 +0800 Subject: [PATCH] test(parser): parse sshd pam auth failures --- README.md | 3 +- ...er_auth_families_journalctl_short_full.log | 3 + assets/parser_auth_families_syslog.log | 3 + docs/parser-contract.md | 2 +- src/parser.cpp | 23 ++++ tests/test_parser.cpp | 120 ++++++++++++++---- 6 files changed, 126 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a34522d..cbacc83 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ LogLens currently parses and reports these additional auth patterns beyond the c - `Accepted keyboard-interactive/pam` SSH successes - `Failed publickey` SSH failures, which count toward SSH brute-force detection by default - `Failed keyboard-interactive/pam` and `maximum authentication attempts exceeded` SSH failures, which count toward SSH brute-force detection by default +- `sshd`-owned `PAM: Authentication failure ...` lines, with invalid/illegal-user variants normalized to `ssh_invalid_user` - `sudo` command, password-failure, and sudoers policy-denial audit lines - `su` success and failure audit lines - `pam_unix(...:auth): authentication failure` @@ -95,7 +96,7 @@ LogLens does not currently detect: - Lateral movement - MFA abuse - SSH key misuse -- Many PAM-specific failures beyond the parsed `pam_unix`, `pam_faillock`, and `pam_sss` sample patterns +- Many PAM-specific failures beyond the parsed `sshd`, `pam_unix`, `pam_faillock`, and `pam_sss` sample patterns - Cross-file or cross-host correlation ## Build diff --git a/assets/parser_auth_families_journalctl_short_full.log b/assets/parser_auth_families_journalctl_short_full.log index f823b9e..cecd9ce 100644 --- a/assets/parser_auth_families_journalctl_short_full.log +++ b/assets/parser_auth_families_journalctl_short_full.log @@ -1,6 +1,9 @@ Wed 2026-03-11 10:00:01 UTC example-host sshd[3100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY Wed 2026-03-11 10:00:20 UTC example-host sshd[3101]: Accepted password for bob from 203.0.113.73 port 53001 ssh2 Wed 2026-03-11 10:00:36 UTC example-host sshd[3102]: Failed publickey for invalid user svc-deploy from 203.0.113.74 port 53002 ssh2 +Wed 2026-03-11 10:00:38 UTC example-host sshd[3103]: PAM: Authentication failure for alice from 203.0.113.76 +Wed 2026-03-11 10:00:39 UTC example-host sshd[3104]: PAM: Authentication failure for invalid user svc-pam from 203.0.113.77 +Wed 2026-03-11 10:00:40 UTC example-host sshd[3105]: PAM: Authentication failure for illegal user svc-pam-legacy from 203.0.113.78 Wed 2026-03-11 10:00:42 UTC example-host pam_faillock(sshd:auth): Consecutive login failures for user alice account temporarily locked from 203.0.113.71 Wed 2026-03-11 10:01:13 UTC example-host pam_faillock(sshd:auth): Authentication failure for user bob from 203.0.113.72 Wed 2026-03-11 10:01:40 UTC example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.75 user=carol diff --git a/assets/parser_auth_families_syslog.log b/assets/parser_auth_families_syslog.log index 5475ffa..9f42b07 100644 --- a/assets/parser_auth_families_syslog.log +++ b/assets/parser_auth_families_syslog.log @@ -1,6 +1,9 @@ Mar 11 10:00:01 example-host sshd[2100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY Mar 11 10:00:20 example-host sshd[2101]: Accepted password for bob from 203.0.113.73 port 53001 ssh2 Mar 11 10:00:36 example-host sshd[2102]: Failed publickey for invalid user svc-deploy from 203.0.113.74 port 53002 ssh2 +Mar 11 10:00:38 example-host sshd[2103]: PAM: Authentication failure for alice from 203.0.113.76 +Mar 11 10:00:39 example-host sshd[2104]: PAM: Authentication failure for invalid user svc-pam from 203.0.113.77 +Mar 11 10:00:40 example-host sshd[2105]: PAM: Authentication failure for illegal user svc-pam-legacy from 203.0.113.78 Mar 11 10:00:42 example-host pam_faillock(sshd:auth): Consecutive login failures for user alice account temporarily locked from 203.0.113.71 Mar 11 10:01:13 example-host pam_faillock(sshd:auth): Authentication failure for user bob from 203.0.113.72 Mar 11 10:01:40 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.75 user=carol diff --git a/docs/parser-contract.md b/docs/parser-contract.md index 71989b5..94a8a38 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, illegal user, failed publickey, failed keyboard-interactive/pam, failed-none invalid-user probing, and maximum-authentication-attempts-exceeded lines. `illegal user` is treated as an OpenSSH wording variant of `invalid user`. Maximum-authentication-attempts lines may include OpenSSH's leading `error:` marker and still normalize into the same event family. Invalid or illegal-user variants of failed-none probing, 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, failed-none invalid-user probing, `sshd`-owned PAM authentication-failure lines, and maximum-authentication-attempts-exceeded lines. `illegal user` is treated as an OpenSSH wording variant of `invalid user`. Maximum-authentication-attempts lines may include OpenSSH's leading `error:` marker and still normalize into the same event family. Invalid or illegal-user variants of failed-none probing, keyboard-interactive, `sshd`-owned PAM authentication failures, 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 6ab9652..67464ae 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -453,6 +453,26 @@ bool parse_ssh_max_auth_tries_message(std::string_view message, Event& event) { return true; } +bool parse_ssh_pam_auth_failure_message(std::string_view message, Event& event) { + static constexpr std::string_view pam_auth_prefix = "PAM: Authentication failure for "; + if (!message.starts_with(pam_auth_prefix)) { + return false; + } + + auto remaining = message.substr(pam_auth_prefix.size()); + const bool invalid_user = consume_invalid_or_illegal_user_prefix(remaining); + + const auto username = consume_token(remaining); + if (username.empty()) { + return false; + } + + event.username.assign(username); + event.source_ip = extract_token_after(message, " from "); + event.event_type = invalid_user ? EventType::SshInvalidUser : EventType::PamAuthFailure; + return true; +} + bool parse_ssh_invalid_user_message(std::string_view message, Event& event) { static constexpr std::string_view invalid_user_prefix = "Invalid user "; static constexpr std::string_view illegal_user_prefix = "Illegal user "; @@ -745,6 +765,9 @@ bool classify_event(Event& event) { if (parse_ssh_max_auth_tries_message(message, event)) { return true; } + if (parse_ssh_pam_auth_failure_message(message, event)) { + return true; + } if (parse_ssh_invalid_user_message(message, event)) { return true; } diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index 1895465..9022bbb 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -355,6 +355,48 @@ void test_max_auth_tries_illegal_user_event() { "expected max-auth-tries illegal-user type"); } +void test_ssh_pam_auth_failure_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:28:40 example-host sshd[1251]: PAM: Authentication failure for alice from 203.0.113.84", + 6); + + expect(event.has_value(), "expected sshd-owned pam auth failure event"); + expect(event->program == "sshd", "expected sshd program"); + expect(event->username == "alice", "expected sshd-owned pam username"); + expect(event->source_ip == "203.0.113.84", "expected sshd-owned pam source ip"); + expect(event->event_type == loglens::EventType::PamAuthFailure, + "expected sshd-owned pam auth failure type"); +} + +void test_ssh_pam_auth_failure_invalid_user_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:28:41 example-host sshd[1252]: PAM: Authentication failure for invalid user svc-pam from 203.0.113.85", + 6); + + expect(event.has_value(), "expected sshd-owned pam invalid-user event"); + expect(event->program == "sshd", "expected sshd program for invalid-user pam line"); + expect(event->username == "svc-pam", "expected sshd-owned pam invalid username"); + expect(event->source_ip == "203.0.113.85", "expected sshd-owned pam invalid source ip"); + expect(event->event_type == loglens::EventType::SshInvalidUser, + "expected sshd-owned pam invalid-user type"); +} + +void test_ssh_pam_auth_failure_illegal_user_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:28:42 example-host sshd[1253]: PAM: Authentication failure for illegal user svc-pam-legacy from 203.0.113.86", + 6); + + expect(event.has_value(), "expected sshd-owned pam illegal-user event"); + expect(event->program == "sshd", "expected sshd program for illegal-user pam line"); + expect(event->username == "svc-pam-legacy", "expected sshd-owned pam illegal username"); + expect(event->source_ip == "203.0.113.86", "expected sshd-owned pam illegal source ip"); + expect(event->event_type == loglens::EventType::SshInvalidUser, + "expected sshd-owned pam illegal-user type"); +} + void test_pam_auth_failure_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( @@ -454,12 +496,12 @@ void test_syslog_auth_family_fixture_file() { const auto parser = make_syslog_parser(); const auto result = parser.parse_file(asset_path("parser_auth_families_syslog.log")); - expect(result.events.size() == 8, "expected eight recognized syslog auth-family events"); + expect(result.events.size() == 11, "expected eleven recognized syslog auth-family events"); expect(result.warnings.size() == 5, "expected five syslog auth-family warnings"); - expect(result.quality.total_lines == 13, "expected thirteen syslog auth-family lines"); - expect(result.quality.parsed_lines == 8, "expected eight parsed syslog auth-family lines"); + expect(result.quality.total_lines == 16, "expected sixteen syslog auth-family lines"); + expect(result.quality.parsed_lines == 11, "expected eleven parsed syslog auth-family lines"); expect(result.quality.unparsed_lines == 5, "expected five unparsed syslog auth-family lines"); - expect_close(result.quality.parse_success_rate, 8.0 / 13.0, 1e-9, + expect_close(result.quality.parse_success_rate, 11.0 / 16.0, 1e-9, "expected syslog auth-family parse success rate"); expect(result.events[0].event_type == loglens::EventType::SshAcceptedPublicKey, @@ -472,24 +514,36 @@ void test_syslog_auth_family_fixture_file() { "expected failed publickey auth-family event"); expect(result.events[2].username == "svc-deploy", "expected failed publickey username"); expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, + "expected sshd-owned pam auth-family event"); + expect(result.events[3].username == "alice", "expected sshd-owned pam username"); + expect(result.events[3].source_ip == "203.0.113.76", "expected sshd-owned pam source ip"); + expect(result.events[4].event_type == loglens::EventType::SshInvalidUser, + "expected sshd-owned pam invalid-user auth-family event"); + expect(result.events[4].username == "svc-pam", "expected sshd-owned pam invalid username"); + expect(result.events[4].source_ip == "203.0.113.77", "expected sshd-owned pam invalid source ip"); + expect(result.events[5].event_type == loglens::EventType::SshInvalidUser, + "expected sshd-owned pam illegal-user auth-family event"); + expect(result.events[5].username == "svc-pam-legacy", "expected sshd-owned pam illegal username"); + expect(result.events[5].source_ip == "203.0.113.78", "expected sshd-owned pam illegal source ip"); + expect(result.events[6].event_type == loglens::EventType::PamAuthFailure, "expected pam_faillock preauth auth-family event"); - expect(result.events[3].username == "alice", "expected pam_faillock preauth username"); - expect(result.events[3].source_ip == "203.0.113.71", "expected pam_faillock preauth source ip"); - expect(result.events[4].event_type == loglens::EventType::PamAuthFailure, + expect(result.events[6].username == "alice", "expected pam_faillock preauth username"); + expect(result.events[6].source_ip == "203.0.113.71", "expected pam_faillock preauth source ip"); + expect(result.events[7].event_type == loglens::EventType::PamAuthFailure, "expected pam_faillock authfail auth-family event"); - expect(result.events[4].username == "bob", "expected pam_faillock authfail username"); - expect(result.events[4].source_ip == "203.0.113.72", "expected pam_faillock authfail source ip"); - expect(result.events[5].event_type == loglens::EventType::PamAuthFailure, + expect(result.events[7].username == "bob", "expected pam_faillock authfail username"); + expect(result.events[7].source_ip == "203.0.113.72", "expected pam_faillock authfail source ip"); + expect(result.events[8].event_type == loglens::EventType::PamAuthFailure, "expected pam_unix auth-family event"); - expect(result.events[5].username == "carol", "expected pam_unix auth-family username"); - expect(result.events[5].source_ip == "203.0.113.75", "expected pam_unix auth-family source ip"); - expect(result.events[6].event_type == loglens::EventType::PamAuthFailure, + expect(result.events[8].username == "carol", "expected pam_unix auth-family username"); + expect(result.events[8].source_ip == "203.0.113.75", "expected pam_unix auth-family source ip"); + expect(result.events[9].event_type == loglens::EventType::PamAuthFailure, "expected pam_sss failure auth-family event"); - expect(result.events[6].username == "dave", "expected pam_sss failure username"); - expect(result.events[6].source_ip.empty(), "expected pam_sss failure fixture to stay source-less"); - expect(result.events[7].event_type == loglens::EventType::SessionOpened, + expect(result.events[9].username == "dave", "expected pam_sss failure username"); + expect(result.events[9].source_ip.empty(), "expected pam_sss failure fixture to stay source-less"); + expect(result.events[10].event_type == loglens::EventType::SessionOpened, "expected pam_unix session-opened auth-family event"); - expect(result.events[7].username == "erin", "expected pam_unix session-opened username"); + expect(result.events[10].username == "erin", "expected pam_unix session-opened username"); expect(result.quality.top_unknown_patterns.size() == 5, "expected five syslog auth-family buckets"); expect(result.quality.top_unknown_patterns[0].pattern == "pam_faillock_authsucc", @@ -513,12 +567,12 @@ void test_journalctl_auth_family_fixture_file() { const auto parser = make_journalctl_parser(); const auto result = parser.parse_file(asset_path("parser_auth_families_journalctl_short_full.log")); - expect(result.events.size() == 8, "expected eight recognized journalctl auth-family events"); + expect(result.events.size() == 11, "expected eleven recognized journalctl auth-family events"); expect(result.warnings.size() == 5, "expected five journalctl auth-family warnings"); - expect(result.quality.total_lines == 13, "expected thirteen journalctl auth-family lines"); - expect(result.quality.parsed_lines == 8, "expected eight parsed journalctl auth-family lines"); + expect(result.quality.total_lines == 16, "expected sixteen journalctl auth-family lines"); + expect(result.quality.parsed_lines == 11, "expected eleven parsed journalctl auth-family lines"); expect(result.quality.unparsed_lines == 5, "expected five unparsed journalctl auth-family lines"); - expect_close(result.quality.parse_success_rate, 8.0 / 13.0, 1e-9, + expect_close(result.quality.parse_success_rate, 11.0 / 16.0, 1e-9, "expected journalctl auth-family parse success rate"); expect(result.events[0].event_type == loglens::EventType::SshAcceptedPublicKey, @@ -529,15 +583,26 @@ void test_journalctl_auth_family_fixture_file() { expect(result.events[2].event_type == loglens::EventType::SshFailedPublicKey, "expected journalctl failed publickey auth-family event"); expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, + "expected journalctl sshd-owned pam auth-family event"); + expect(result.events[3].source_ip == "203.0.113.76", + "expected journalctl sshd-owned pam source ip"); + expect(result.events[4].event_type == loglens::EventType::SshInvalidUser, + "expected journalctl sshd-owned pam invalid-user auth-family event"); + expect(result.events[4].username == "svc-pam", "expected journalctl sshd-owned pam invalid username"); + expect(result.events[5].event_type == loglens::EventType::SshInvalidUser, + "expected journalctl sshd-owned pam illegal-user auth-family event"); + expect(result.events[5].username == "svc-pam-legacy", + "expected journalctl sshd-owned pam illegal username"); + expect(result.events[6].event_type == loglens::EventType::PamAuthFailure, "expected journalctl pam_faillock preauth auth-family event"); - expect(result.events[4].event_type == loglens::EventType::PamAuthFailure, + expect(result.events[7].event_type == loglens::EventType::PamAuthFailure, "expected journalctl pam_faillock authfail auth-family event"); - expect(result.events[5].event_type == loglens::EventType::PamAuthFailure, + expect(result.events[8].event_type == loglens::EventType::PamAuthFailure, "expected journalctl pam_unix auth-family event"); - expect(result.events[6].event_type == loglens::EventType::PamAuthFailure, + expect(result.events[9].event_type == loglens::EventType::PamAuthFailure, "expected journalctl pam_sss failure auth-family event"); - expect(result.events[6].source_ip.empty(), "expected journalctl pam_sss failure fixture to stay source-less"); - expect(result.events[7].event_type == loglens::EventType::SessionOpened, + expect(result.events[9].source_ip.empty(), "expected journalctl pam_sss failure fixture to stay source-less"); + expect(result.events[10].event_type == loglens::EventType::SessionOpened, "expected journalctl pam_unix session-opened auth-family event"); expect(result.quality.top_unknown_patterns.size() == 5, "expected five journalctl auth-family buckets"); @@ -843,6 +908,9 @@ int main() { test_max_auth_tries_error_prefix_event(); test_max_auth_tries_invalid_user_event(); test_max_auth_tries_illegal_user_event(); + test_ssh_pam_auth_failure_event(); + test_ssh_pam_auth_failure_invalid_user_event(); + test_ssh_pam_auth_failure_illegal_user_event(); test_pam_auth_failure_event(); test_pam_sss_received_failure_event(); test_session_opened_event();