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: 2 additions & 0 deletions assets/parser_fixture_matrix_journalctl_short_full.log
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions assets/parser_fixture_matrix_syslog.log
Original file line number Diff line number Diff line change
Expand Up @@ -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]
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, 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.

Expand Down
46 changes: 26 additions & 20 deletions src/parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -285,18 +285,31 @@ 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)) {
return false;
}

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()) {
Expand Down Expand Up @@ -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()) {
Expand All @@ -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()) {
Expand All @@ -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()) {
Expand All @@ -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;
Expand Down
98 changes: 90 additions & 8 deletions tests/test_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand All @@ -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");
Expand Down Expand Up @@ -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",
Expand All @@ -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();
Expand All @@ -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();
Expand Down
Loading