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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions assets/parser_auth_families_journalctl_short_full.log
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions assets/parser_auth_families_syslog.log
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/parser-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 23 additions & 0 deletions src/parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 ";
Expand Down Expand Up @@ -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;
}
Expand Down
120 changes: 94 additions & 26 deletions tests/test_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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");
Expand Down Expand Up @@ -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();
Expand Down
Loading