diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c8d2a..4bb202e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ All notable user-visible changes should be recorded here. result invariants. - Added an optional Clang libFuzzer parser target with a sanitized seed corpus and bounded Ubuntu CI smoke campaign. +- Added a dedicated util-linux `login` handler for selected failed-login, + retry-exhaustion, session-failure, local-login, and root-login messages. +- Expanded the sanitized mixed auth corpus from 150 to 160 lines with ten + fixture-backed `login` records and updated parser coverage telemetry. ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index 580c0e2..019f3c4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ set(LOGLENS_LIBRARY_SOURCES src/config.cpp src/parser.cpp src/parser/failure_classifier.cpp + src/parser/login_handlers.cpp src/parser/pam_handlers.cpp src/parser/program_dispatch.cpp src/parser/source_envelope_parser.cpp diff --git a/README.md b/README.md index e753257..2889d4d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ LogLens currently parses and reports these additional auth patterns beyond the c - `sshd`-owned `PAM: Authentication failure ...` lines, including OpenSSH's optional leading `error:` marker, 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 +- selected util-linux `login` failure, retry-exhaustion, session-failure, + local-login, and root-login audit lines - `pam_unix(...:auth): authentication failure` - `pam_unix(...:session): session opened` - selected `pam_faillock(...:auth)` failure variants diff --git a/assets/mixed_auth_corpus.log b/assets/mixed_auth_corpus.log index 4b1b71d..5e2beb9 100644 --- a/assets/mixed_auth_corpus.log +++ b/assets/mixed_auth_corpus.log @@ -148,3 +148,13 @@ Mar 12 08:09:45 mixed-auth-10 cron[5008]: (root) CMD (/usr/bin/true) Mar 12 08:09:49 mixed-auth-10 sshd[5009]: Failed password for invalid user user010 from 999.0.113.106 port 51905 ssh2 Foo 12 08:09:53 mixed-auth-10 sshd[5010]: Failed password for invalid user user010 from 203.0.113.107 port 51906 ssh2 +Mar 12 08:10:01 mixed-auth-11 login[5101]: FAILED LOGIN 1 FROM tty1 FOR user011, Authentication failure +Mar 12 08:10:05 mixed-auth-11 login[5102]: TOO MANY LOGIN TRIES (3) FROM tty1 FOR user011, Authentication failure +Mar 12 08:10:09 mixed-auth-11 login[5103]: FAILED LOGIN SESSION FROM tty1 FOR user011, Authentication failure +Mar 12 08:10:13 mixed-auth-11 login[5104]: LOGIN ON tty1 BY user011 +Mar 12 08:10:17 mixed-auth-11 login[5105]: ROOT LOGIN ON tty2 +Mar 12 08:10:21 mixed-auth-12 login[5201]: FAILED LOGIN 1 FROM tty3 FOR user012, Authentication failure +Mar 12 08:10:25 mixed-auth-12 login[5202]: TOO MANY LOGIN TRIES (3) FROM tty3 FOR user012, Authentication failure +Mar 12 08:10:29 mixed-auth-12 login[5203]: FAILED LOGIN SESSION FROM tty3 FOR user012, Authentication failure +Mar 12 08:10:33 mixed-auth-12 login[5204]: LOGIN ON tty3 BY user012 FROM example-console +Mar 12 08:10:37 mixed-auth-13 login[5301]: LOGIN ON tty4 BY user013 diff --git a/assets/mixed_auth_parser_coverage.json b/assets/mixed_auth_parser_coverage.json index 0c5e638..c932e86 100644 --- a/assets/mixed_auth_parser_coverage.json +++ b/assets/mixed_auth_parser_coverage.json @@ -5,12 +5,12 @@ "input_mode": "syslog_legacy", "assume_year": 2026, "parser_quality": { - "total_input_lines": 150, - "total_lines": 140, + "total_input_lines": 160, + "total_lines": 150, "skipped_blank_lines": 10, - "parsed_lines": 90, + "parsed_lines": 100, "unparsed_lines": 50, - "parse_success_rate": 0.6428571429, + "parse_success_rate": 0.6666666667, "top_unknown_patterns": [ {"pattern": "invalid_month_token", "count": 10}, {"pattern": "malformed_source_ip", "count": 10}, @@ -26,13 +26,14 @@ {"category": "unsupported_pam_variant", "count": 10} ] }, - "parsed_event_count": 90, + "parsed_event_count": 100, "warning_count": 50, "event_type_counts": [ {"event_type": "ssh_accepted_publickey", "count": 10}, {"event_type": "ssh_invalid_user", "count": 10}, {"event_type": "ssh_failed_publickey", "count": 10}, - {"event_type": "pam_auth_failure", "count": 30}, + {"event_type": "pam_auth_failure", "count": 36}, + {"event_type": "session_opened", "count": 4}, {"event_type": "sudo_command", "count": 10}, {"event_type": "sudo_auth_failure", "count": 10}, {"event_type": "su_auth_failure", "count": 10} diff --git a/docs/case-study-parser-uncertainty-as-evidence.md b/docs/case-study-parser-uncertainty-as-evidence.md index caedae8..ef3f258 100644 --- a/docs/case-study-parser-uncertainty-as-evidence.md +++ b/docs/case-study-parser-uncertainty-as-evidence.md @@ -33,7 +33,7 @@ the narrower engineering question. The checked-in [`mixed_auth_corpus.log`](../assets/mixed_auth_corpus.log) is a sanitized, -150-line syslog-style fixture. Its paired +160-line syslog-style fixture. Its paired [`mixed_auth_parser_coverage.json`](../assets/mixed_auth_parser_coverage.json) records recognized events, warnings, blank lines, failure categories, pattern buckets, and source-line references. diff --git a/docs/parser-conformance-matrix.md b/docs/parser-conformance-matrix.md index 31b9f59..b8df24e 100644 --- a/docs/parser-conformance-matrix.md +++ b/docs/parser-conformance-matrix.md @@ -47,6 +47,7 @@ coverage of every distro or PAM module wording. | `sshd` | `sshd[pid]: message` or `sshd: message` | Failed password, failed password for invalid/illegal user, failed none for invalid/illegal user, direct invalid/illegal user, `input_userauth_request` invalid/illegal user, accepted password, accepted publickey, accepted keyboard-interactive/pam, failed publickey, failed keyboard-interactive/pam, maximum-authentication-attempts exceeded, and `sshd`-owned `PAM: Authentication failure` lines | `ssh_failed_password`, `ssh_invalid_user`, `ssh_accepted_password`, `ssh_accepted_publickey`, `ssh_accepted_keyboard_interactive`, `ssh_failed_publickey`, `ssh_failed_keyboard_interactive`, `ssh_max_auth_tries`, `pam_auth_failure` | Preauth close/reset, timeout/disconnection, negotiation failure, and other unsupported `sshd` messages remain parser warnings such as `sshd_connection_closed_preauth`, `sshd_timeout_or_disconnection`, `sshd_negotiation_failure`, or `sshd_other` | | `sudo` | `sudo[pid]: : ...` or `sudo: : ...` | Command audit lines with `COMMAND=`, incorrect-password audit lines, user-not-in-sudoers denials, and command-not-allowed denials | `sudo_command`, `sudo_auth_failure`, `sudo_policy_denied` | Other well-formed sudo-like messages remain `sudo_other` parser warnings and do not count as sudo burst evidence | | `su` | `su[pid]: message` or `su: message` | `FAILED SU (to ) on ` and `Successful su for by ` | `su_auth_failure`, `session_opened` | Other well-formed `su` messages remain `su_other` parser warnings | +| `login` | `login[pid]: message` or `login: message` | Selected util-linux `FAILED LOGIN`, `TOO MANY LOGIN TRIES`, `FAILED LOGIN SESSION`, `LOGIN ON ... BY ...`, and `ROOT LOGIN ON ...` messages | `pam_auth_failure`, `session_opened` | Localized or other unmodeled `login` messages remain `login_other` parser warnings; no network source IP is inferred from the `FROM` terminal/host field | | `pam_unix` | `pam_unix(:auth): ...` or `pam_unix(:session): ...` | Auth failures carrying `authentication failure` plus selected session-opened lines such as sudo/su session opens | `pam_auth_failure`, `session_opened` | Session-closed and other unsupported `pam_unix` messages remain `pam_unix_session_closed` or `pam_unix_other` parser warnings | | `pam_faillock` | `pam_faillock(:auth): ...` | Selected auth failure variants: `Consecutive login failures for user ... from ` and `Authentication failure for user ... from ` | `pam_auth_failure` | Account-lock telemetry, auth-success telemetry, and other unmodeled variants remain `pam_faillock_account_locked`, `pam_faillock_authsucc`, or `pam_faillock_other` warnings | | `pam_sss` | `pam_sss(:auth): ...` | Selected SSSD auth failure variant: `received for user : 7 (Authentication failure)` | `pam_auth_failure` | Unknown-user, auth-info-unavailable, and other unmodeled variants remain `pam_sss_unknown_user`, `pam_sss_authinfo_unavail`, or `pam_sss_other` warnings | @@ -80,6 +81,8 @@ coverage of every distro or PAM module wording. | Sudo policy denial | ` : user NOT in sudoers ...` or ` : command not allowed ...` | `syslog_legacy`, `journalctl_short_full` | `sudo_policy_denied` | Audit event; not counted as sudo burst evidence by default. | | `su` failure audit | `FAILED SU (to ) on ` | `syslog_legacy`, `journalctl_short_full` | `su_auth_failure` | Normalized `username` is the actor. | | `su` success audit | `Successful su for by ` | `syslog_legacy`, `journalctl_short_full` | `session_opened` | Normalized `username` is the actor. | +| util-linux `login` failure audit | `FAILED LOGIN ... FROM FOR , ...`, `TOO MANY LOGIN TRIES ...`, or `FAILED LOGIN SESSION ...` | `syslog_legacy`, `journalctl_short_full` | `pam_auth_failure` | Extracts `username`; source IP remains empty because `FROM` is not treated as verified network evidence. | +| util-linux `login` success audit | `LOGIN ON BY [FROM ]` or `ROOT LOGIN ON [FROM ]` | `syslog_legacy`, `journalctl_short_full` | `session_opened` | Extracts the actor, or `root` for root-login records; does not imply remote attribution. | ## Unsupported Bucket Matrix @@ -102,6 +105,7 @@ normalized event is always `none`. | Other unsupported `pam_sss(...)` messages | `syslog_legacy`, `journalctl_short_full` | `unsupported_pam_variant` | `pam_sss_other` | none | | Well-formed `sudo` line that is not command, incorrect-password, or policy-denial evidence | `syslog_legacy`, `journalctl_short_full` | `known_program_unknown_message` | `sudo_other` | none | | Well-formed `su` line that is not recognized as success or failure audit evidence | `syslog_legacy`, `journalctl_short_full` | `known_program_unknown_message` | `su_other` | none | +| Well-formed `login` line outside the selected util-linux message shapes | `syslog_legacy`, `journalctl_short_full` | `known_program_unknown_message` | `login_other` | none | | Well-formed unsupported program tag | `syslog_legacy`, `journalctl_short_full` | `unknown_program` | `program_` | none | ## Header And Structural Warning Matrix @@ -133,7 +137,7 @@ coverage telemetry path. | [`assets/parser_auth_families_syslog.log`](../assets/parser_auth_families_syslog.log) | Selected `sshd`, `pam_unix`, `pam_faillock`, `pam_sss`, and session-opened auth-family support, plus five unsupported PAM-family telemetry buckets | | [`assets/parser_auth_families_journalctl_short_full.log`](../assets/parser_auth_families_journalctl_short_full.log) | Same auth-family event and warning shape as the syslog auth-family fixture, with journalctl timestamp parsing | | [`assets/noisy_auth_sample.log`](../assets/noisy_auth_sample.log) and [`tests/fixtures/parser_matrix/noisy_auth_expected.json`](../tests/fixtures/parser_matrix/noisy_auth_expected.json) | Noisy syslog coverage fixture with malformed lines, blank lines, unsupported auth-family evidence, irrelevant service lines, and locked parser quality counts | -| [`assets/mixed_auth_corpus.log`](../assets/mixed_auth_corpus.log) and [`assets/mixed_auth_parser_coverage.json`](../assets/mixed_auth_parser_coverage.json) | 150-line sanitized mixed syslog corpus with Ubuntu / Debian-style `auth.log` and RHEL-family `secure` host labels, 90 parsed events, 50 parser warnings, 10 blank lines, and locked unknown-pattern and failure-category coverage | +| [`assets/mixed_auth_corpus.log`](../assets/mixed_auth_corpus.log) and [`assets/mixed_auth_parser_coverage.json`](../assets/mixed_auth_parser_coverage.json) | 160-line sanitized mixed syslog corpus with Ubuntu / Debian-style `auth.log`, RHEL-family `secure` labels, selected util-linux `login` records, 100 parsed events, 50 parser warnings, 10 blank lines, and locked unknown-pattern and failure-category coverage | ## Review Rule diff --git a/docs/parser-contract.md b/docs/parser-contract.md index 8900356..51d5f3c 100644 --- a/docs/parser-contract.md +++ b/docs/parser-contract.md @@ -28,13 +28,19 @@ The parser currently recognizes common authentication evidence from: - `sshd` - `sudo` - `su` +- `login` - `pam_unix(...)` - 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, `input_userauth_request` invalid/illegal-user preauth traces, `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 and `sshd`-owned PAM authentication-failure 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, `input_userauth_request` preauth traces, 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. +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, selected util-linux `login` failures and session records, and selected PAM session/auth lines. `login` failures do not infer a network source IP and remain lower-confidence `pam_auth_failure` context. + +The selected `login` wording is anchored to the upstream +[`login.c`](https://github.com/util-linux/util-linux/blob/9447bc4f72ac5634dcfd78ea877286279b050369/login-utils/login.c) +syslog strings. Localized or otherwise unmodeled variants remain visible as +`login_other` warnings. ## Line handling contract @@ -115,7 +121,7 @@ Parsed successes and audit-only events remain reportable but do not count as bru | [`assets/parser_auth_families_syslog.log`](../assets/parser_auth_families_syslog.log) | Syslog PAM/auth-family parser coverage | | [`assets/parser_auth_families_journalctl_short_full.log`](../assets/parser_auth_families_journalctl_short_full.log) | Journalctl PAM/auth-family parser coverage | | [`assets/noisy_auth_sample.log`](../assets/noisy_auth_sample.log) and [`tests/fixtures/parser_matrix/noisy_auth_expected.json`](../tests/fixtures/parser_matrix/noisy_auth_expected.json) | Noisy syslog parser-coverage matrix for malformed, unsupported, blank, irrelevant, multi-host, and unusual-username input | -| [`assets/mixed_auth_corpus.log`](../assets/mixed_auth_corpus.log) and [`assets/mixed_auth_parser_coverage.json`](../assets/mixed_auth_parser_coverage.json) | 150-line mixed auth corpus plus reviewer-facing parser coverage artifact for dirty syslog input | +| [`assets/mixed_auth_corpus.log`](../assets/mixed_auth_corpus.log) and [`assets/mixed_auth_parser_coverage.json`](../assets/mixed_auth_parser_coverage.json) | 160-line mixed auth corpus plus reviewer-facing parser coverage artifact for dirty syslog input and selected util-linux `login` evidence | | [`tests/test_report_contracts.cpp`](../tests/test_report_contracts.cpp) | Stable report-shape expectations for generated artifacts | ## Non-goals diff --git a/docs/parser-coverage-notes.md b/docs/parser-coverage-notes.md index 87ffb78..16d1a47 100644 --- a/docs/parser-coverage-notes.md +++ b/docs/parser-coverage-notes.md @@ -25,19 +25,19 @@ The locked expected coverage summary lives in [`tests/fixtures/parser_matrix/noi ## Mixed auth corpus -[`assets/mixed_auth_corpus.log`](../assets/mixed_auth_corpus.log) is a 150-line sanitized `syslog_legacy` corpus for dirty-input review. It mixes Ubuntu / Debian `auth.log`-style and RHEL-family `secure`-style host labels while keeping the same BSD syslog header contract. This is a parser-observability fixture, not a claim of complete distro coverage. +[`assets/mixed_auth_corpus.log`](../assets/mixed_auth_corpus.log) is a 160-line sanitized `syslog_legacy` corpus for dirty-input review. It mixes Ubuntu / Debian `auth.log`-style and RHEL-family `secure`-style host labels while keeping the same BSD syslog header contract. This is a parser-observability fixture, not a claim of complete distro coverage. -The corpus repeats ten small evidence batches. Each batch includes recognized `sshd`, `sudo`, `su`, `pam_unix`, `pam_faillock`, and `pam_sss` evidence; unsupported `sshd` preauth and `pam_unix` session-close telemetry; an unsupported service program; a malformed source IP; an invalid timestamp; and one blank line. +The corpus repeats ten small evidence batches. Each batch includes recognized `sshd`, `sudo`, `su`, `pam_unix`, `pam_faillock`, and `pam_sss` evidence; unsupported `sshd` preauth and `pam_unix` session-close telemetry; an unsupported service program; a malformed source IP; an invalid timestamp; and one blank line. A final ten-line segment adds selected util-linux `login` failure and session records without changing the warning corpus. For reviewer inspection without running the test suite, [`assets/mixed_auth_parser_coverage.json`](../assets/mixed_auth_parser_coverage.json) captures the deterministic parser coverage view for this corpus: parser-quality counters, normalized event-type counts, unknown-pattern buckets, failure categories, and warning line references. Locked parser expectations: -- `total_input_lines`: 150 +- `total_input_lines`: 160 - `skipped_blank_lines`: 10 -- `parsed_lines`: 90 +- `parsed_lines`: 100 - `unparsed_lines`: 50 -- normalized event counts: 10 invalid-user SSH failures, 10 failed-publickey SSH events, 10 accepted-publickey SSH events, 10 sudo command events, 10 sudo auth failures, 30 PAM auth failures, and 10 `su` auth failures +- normalized event counts: 10 invalid-user SSH failures, 10 failed-publickey SSH events, 10 accepted-publickey SSH events, 10 sudo command events, 10 sudo auth failures, 36 PAM auth failures, 4 session-opened events, and 10 `su` auth failures - `failure_categories`: 10 each for `known_program_unknown_message`, `malformed_source_ip`, `unknown_program`, `unknown_timestamp`, and `unsupported_pam_variant` - `top_unknown_patterns`: 10 each for `invalid_month_token`, `malformed_source_ip`, `pam_unix_session_closed`, `program_cron`, and `sshd_connection_closed_preauth` diff --git a/src/parser/failure_classifier.cpp b/src/parser/failure_classifier.cpp index e984d0d..1149c0a 100644 --- a/src/parser/failure_classifier.cpp +++ b/src/parser/failure_classifier.cpp @@ -181,6 +181,10 @@ std::string classify_unknown_auth_pattern(const Event& event) { return "su_other"; } + if (event.program == "login") { + return "login_other"; + } + return "program_" + sanitize_pattern_label(event.program); } @@ -194,6 +198,7 @@ bool is_known_auth_program(std::string_view program) { return program == "sshd" || program == "sudo" || program == "su" + || program == "login" || is_pam_program(program); } diff --git a/src/parser/login_handlers.cpp b/src/parser/login_handlers.cpp new file mode 100644 index 0000000..2d192c2 --- /dev/null +++ b/src/parser/login_handlers.cpp @@ -0,0 +1,143 @@ +#include "parser/login_handlers.hpp" + +#include "parser/failure_classifier.hpp" +#include "parser/text_utils.hpp" + +#include +#include + +namespace loglens::parser_internal { +namespace { + +bool extract_failed_username(std::string_view message, Event& event) { + const auto for_position = message.find(" FOR "); + if (for_position == std::string_view::npos) { + return false; + } + + const auto from_position = message.rfind(" FROM ", for_position); + if (from_position == std::string_view::npos + || trim(message.substr(from_position + std::string_view{" FROM "}.size(), + for_position - from_position - std::string_view{" FROM "}.size())).empty()) { + return false; + } + + auto username = message.substr(for_position + std::string_view{" FOR "}.size()); + const auto comma_position = username.find(','); + if (comma_position == std::string_view::npos) { + return false; + } + + const auto reason = trim(username.substr(comma_position + 1)); + username = trim(username.substr(0, comma_position)); + if (username.empty() || reason.empty()) { + return false; + } + + if (username != "(unknown)") { + event.username.assign(username); + } + return true; +} + +bool parse_login_failure(std::string_view message, Event& event) { + static constexpr std::string_view failed_prefix = "FAILED LOGIN "; + static constexpr std::string_view failed_session_prefix = "FAILED LOGIN SESSION FROM "; + static constexpr std::string_view too_many_prefix = "TOO MANY LOGIN TRIES ("; + + bool failed_login = message.starts_with(failed_session_prefix); + if (!failed_login && message.starts_with(failed_prefix)) { + const auto count_end = message.find(" FROM ", failed_prefix.size()); + int failure_count = 0; + failed_login = count_end != std::string_view::npos + && parse_int(message.substr(failed_prefix.size(), count_end - failed_prefix.size()), failure_count) + && failure_count > 0; + } + + bool too_many_tries = false; + if (message.starts_with(too_many_prefix)) { + const auto count_end = message.find(") FROM ", too_many_prefix.size()); + int failure_count = 0; + too_many_tries = count_end != std::string_view::npos + && parse_int(message.substr(too_many_prefix.size(), count_end - too_many_prefix.size()), failure_count) + && failure_count > 0; + } + + if (!failed_login && !too_many_tries) { + return false; + } + + if (!extract_failed_username(message, event)) { + return false; + } + + event.event_type = EventType::PamAuthFailure; + return true; +} + +bool parse_login_success(std::string_view message, Event& event) { + static constexpr std::string_view root_prefix = "ROOT LOGIN ON "; + static constexpr std::string_view login_prefix = "LOGIN ON "; + + if (message.starts_with(root_prefix)) { + auto terminal = message.substr(root_prefix.size()); + const auto from_position = terminal.find(" FROM "); + if (from_position != std::string_view::npos) { + if (trim(terminal.substr(from_position + std::string_view{" FROM "}.size())).empty()) { + return false; + } + terminal = terminal.substr(0, from_position); + } + if (trim(terminal).empty()) { + return false; + } + event.username = "root"; + event.event_type = EventType::SessionOpened; + return true; + } + + if (!message.starts_with(login_prefix)) { + return false; + } + + const auto by_position = message.find(" BY "); + if (by_position == std::string_view::npos) { + return false; + } + + if (trim(message.substr(login_prefix.size(), by_position - login_prefix.size())).empty()) { + return false; + } + + auto username = message.substr(by_position + std::string_view{" BY "}.size()); + const auto from_position = username.find(" FROM "); + if (from_position != std::string_view::npos) { + if (trim(username.substr(from_position + std::string_view{" FROM "}.size())).empty()) { + return false; + } + username = username.substr(0, from_position); + } + + username = trim(username); + if (username.empty()) { + return false; + } + + event.username.assign(username); + event.event_type = EventType::SessionOpened; + return true; +} + +} // namespace + +HandlerResult handle_login_event(const Event& source) { + Event event = source; + const auto message = std::string_view{event.message}; + if (parse_login_failure(message, event) || parse_login_success(message, event)) { + return matched_event(std::move(event)); + } + + return classify_unrecognized_event(source); +} + +} // namespace loglens::parser_internal diff --git a/src/parser/login_handlers.hpp b/src/parser/login_handlers.hpp new file mode 100644 index 0000000..c624a00 --- /dev/null +++ b/src/parser/login_handlers.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include "parser/handler_result.hpp" + +namespace loglens::parser_internal { + +HandlerResult handle_login_event(const Event& source); + +} // namespace loglens::parser_internal diff --git a/src/parser/program_dispatch.cpp b/src/parser/program_dispatch.cpp index d5751e7..2179a71 100644 --- a/src/parser/program_dispatch.cpp +++ b/src/parser/program_dispatch.cpp @@ -1,6 +1,7 @@ #include "parser/program_dispatch.hpp" #include "parser/failure_classifier.hpp" +#include "parser/login_handlers.hpp" #include "parser/pam_handlers.hpp" #include "parser/sshd_handlers.hpp" #include "parser/su_handlers.hpp" @@ -35,13 +36,18 @@ bool is_su(std::string_view program) { return program == "su"; } -constexpr std::array handler_registry{{ +bool is_login(std::string_view program) { + return program == "login"; +} + +constexpr std::array handler_registry{{ {is_sshd, handle_sshd_event}, {is_pam_unix, handle_pam_unix_event}, {is_pam_faillock, handle_pam_faillock_event}, {is_pam_sss, handle_pam_sss_event}, {is_sudo, handle_sudo_event}, {is_su, handle_su_event}, + {is_login, handle_login_event}, }}; } // namespace diff --git a/tests/fuzz/corpus/parser/login_failure b/tests/fuzz/corpus/parser/login_failure new file mode 100644 index 0000000..abf616d --- /dev/null +++ b/tests/fuzz/corpus/parser/login_failure @@ -0,0 +1 @@ +Mar 10 08:40:06 example-host login[4406]: FAILED LOGIN 1 FROM tty1 FOR user-f, Authentication failure diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index b412d24..f9d9741 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -1263,14 +1263,14 @@ void test_mixed_auth_corpus_fixture_file() { const auto parser = make_syslog_parser(); const auto result = parser.parse_file(asset_path("mixed_auth_corpus.log")); - expect(total_input_lines(result) == 150, "expected mixed auth corpus total input line count"); - expect(result.quality.total_lines == 140, "expected mixed auth corpus nonblank line count"); + expect(total_input_lines(result) == 160, "expected mixed auth corpus total input line count"); + expect(result.quality.total_lines == 150, "expected mixed auth corpus nonblank line count"); expect(result.quality.skipped_blank_lines == 10, "expected mixed auth corpus skipped blank line count"); - expect(result.events.size() == 90, "expected ninety mixed auth corpus parsed events"); + expect(result.events.size() == 100, "expected one hundred mixed auth corpus parsed events"); expect(result.warnings.size() == 50, "expected fifty mixed auth corpus warnings"); - expect(result.quality.parsed_lines == 90, "expected mixed auth corpus parsed line count"); + expect(result.quality.parsed_lines == 100, "expected mixed auth corpus parsed line count"); expect(result.quality.unparsed_lines == 50, "expected mixed auth corpus unparsed line count"); - expect_close(result.quality.parse_success_rate, 90.0 / 140.0, 1e-9, + expect_close(result.quality.parse_success_rate, 100.0 / 150.0, 1e-9, "expected mixed auth corpus parse success rate"); expect(event_count(result.events, loglens::EventType::SshInvalidUser) == 10, @@ -1283,8 +1283,10 @@ void test_mixed_auth_corpus_fixture_file() { "expected ten mixed corpus sudo command events"); expect(event_count(result.events, loglens::EventType::SudoAuthFailure) == 10, "expected ten mixed corpus sudo auth-failure events"); - expect(event_count(result.events, loglens::EventType::PamAuthFailure) == 30, - "expected thirty mixed corpus PAM auth-failure events"); + expect(event_count(result.events, loglens::EventType::PamAuthFailure) == 36, + "expected thirty-six mixed corpus PAM auth-failure events"); + expect(event_count(result.events, loglens::EventType::SessionOpened) == 4, + "expected four mixed corpus session-opened events"); expect(event_count(result.events, loglens::EventType::SuAuthFailure) == 10, "expected ten mixed corpus su auth-failure events"); @@ -1315,6 +1317,94 @@ void test_mixed_auth_corpus_fixture_file() { expect(actual == expected, "expected mixed auth parser coverage artifact to match fixture"); } +void test_login_handler_normalizes_selected_util_linux_messages() { + struct LoginCase { + std::string message; + loglens::EventType event_type; + std::string username; + }; + + const std::vector cases{ + {"FAILED LOGIN 1 FROM tty1 FOR user-a, Authentication failure", + loglens::EventType::PamAuthFailure, + "user-a"}, + {"TOO MANY LOGIN TRIES (3) FROM tty1 FOR user-b, Authentication failure", + loglens::EventType::PamAuthFailure, + "user-b"}, + {"FAILED LOGIN SESSION FROM tty1 FOR user-c, Authentication failure", + loglens::EventType::PamAuthFailure, + "user-c"}, + {"FAILED LOGIN 1 FROM tty1 FOR (unknown), Authentication failure", + loglens::EventType::PamAuthFailure, + ""}, + {"LOGIN ON tty1 BY user-d", loglens::EventType::SessionOpened, "user-d"}, + {"LOGIN ON tty1 BY user-e FROM example-console", loglens::EventType::SessionOpened, "user-e"}, + {"ROOT LOGIN ON tty2", loglens::EventType::SessionOpened, "root"}, + }; + + const auto parser = make_syslog_parser(); + for (std::size_t index = 0; index < cases.size(); ++index) { + const auto line = "Mar 10 08:19:00 example-host login[4050]: " + cases[index].message; + const auto event = parser.parse_line(line, index + 1); + + expect(event.has_value(), "expected selected util-linux login message to emit an event"); + expect(event->program == "login", "expected login program to be preserved"); + expect(event->event_type == cases[index].event_type, "expected login message event type"); + expect(event->username == cases[index].username, "expected login message username"); + expect(event->source_ip.empty(), "expected login message not to infer a network source IP"); + } +} + +void test_login_handler_keeps_unsupported_message_visible() { + const auto parser = make_syslog_parser(); + const std::vector messages{ + "DIALUP AT ttyS0 BY user-a", + "FAILED LOGIN many FROM tty1 FOR user-a, Authentication failure", + "FAILED LOGIN 1 FROM FOR user-a, Authentication failure", + "FAILED LOGIN 1 FROM tty1 FOR user-a,", + "LOGIN ON BY user-a", + "LOGIN ON tty1 BY user-a FROM ", + "ROOT LOGIN ON FROM example-console", + "ROOT LOGIN ON tty1 FROM ", + }; + + for (std::size_t index = 0; index < messages.size(); ++index) { + std::string reason; + auto category = loglens::ParserFailureCategory::UnknownProgram; + const auto event = parser.parse_line( + "Mar 10 08:19:10 example-host login[4051]: " + messages[index], + index + 1, + &reason, + &category); + + expect(!event.has_value(), "expected unsupported login message not to emit an event"); + expect(category == loglens::ParserFailureCategory::KnownProgramUnknownMessage, + "expected unsupported login message to remain a known-program failure"); + expect(reason == "unrecognized auth pattern: login_other", + "expected stable unsupported login pattern bucket"); + } +} + +void test_journalctl_login_handler_variants() { + const auto parser = make_journalctl_parser(); + const auto failure = parser.parse_line( + "Mon 2026-03-10 08:19:20 UTC example-host login[4052]: " + "FAILED LOGIN 1 FROM tty1 FOR user-a, Authentication failure", + 1); + const auto success = parser.parse_line( + "Mon 2026-03-10 08:19:21 UTC example-host login[4053]: LOGIN ON tty1 BY user-b", + 2); + + expect(failure.has_value(), "expected journalctl login failure event"); + expect(failure->event_type == loglens::EventType::PamAuthFailure, + "expected journalctl login failure normalization"); + expect(failure->username == "user-a", "expected journalctl login failure username"); + expect(success.has_value(), "expected journalctl login success event"); + expect(success->event_type == loglens::EventType::SessionOpened, + "expected journalctl login success normalization"); + expect(success->username == "user-b", "expected journalctl login success username"); +} + void test_program_handler_registry_routes_supported_families() { struct RegistryCase { std::string line; @@ -1341,6 +1431,9 @@ void test_program_handler_registry_routes_supported_families() { {"Mar 10 08:20:06 example-host su[4106]: FAILED SU (to root) user-f on pts/2", "su", loglens::EventType::SuAuthFailure}, + {"Mar 10 08:20:07 example-host login[4107]: FAILED LOGIN 1 FROM tty1 FOR user-g, Authentication failure", + "login", + loglens::EventType::PamAuthFailure}, }; const auto parser = make_syslog_parser(); @@ -1417,6 +1510,9 @@ int main() { test_journalctl_fixture_matrix_file(); test_noisy_auth_fixture_matrix_file(); test_mixed_auth_corpus_fixture_file(); + test_login_handler_normalizes_selected_util_linux_messages(); + test_login_handler_keeps_unsupported_message_visible(); + test_journalctl_login_handler_variants(); test_program_handler_registry_routes_supported_families(); test_parse_stream_accepts_crlf_line_terminator(); return 0; diff --git a/tests/test_parser_properties.cpp b/tests/test_parser_properties.cpp index c7073b1..34495fc 100644 --- a/tests/test_parser_properties.cpp +++ b/tests/test_parser_properties.cpp @@ -60,7 +60,7 @@ loglens::Event make_source_event(std::string program, std::string message) { return event; } -std::array representative_registry_events() { +std::array representative_registry_events() { return { make_source_event( "sshd", @@ -80,6 +80,9 @@ std::array representative_registry_events() { make_source_event( "su", "FAILED SU (to root) user-f on pts/2"), + make_source_event( + "login", + "FAILED LOGIN 1 FROM tty1 FOR user-g, Authentication failure"), }; } @@ -117,7 +120,7 @@ void test_registry_dispatch_is_order_independent() { ++permutation_count; } while (std::next_permutation(order.begin(), order.end())); - expect(permutation_count == 720, "expected all six-handler registry permutations to be checked"); + expect(permutation_count == 5040, "expected all seven-handler registry permutations to be checked"); } std::uint32_t next_random(std::uint32_t& state) { @@ -175,7 +178,7 @@ void test_failure_classification_is_stable_across_envelope_variants() { std::string reason; }; - const std::array cases{{ + const std::array cases{{ {"sshd", "Connection closed by 203.0.113.50 port 50100 [preauth]", loglens::ParserFailureCategory::KnownProgramUnknownMessage, "unrecognized auth pattern: sshd_connection_closed_preauth"}, @@ -194,6 +197,9 @@ void test_failure_classification_is_stable_across_envelope_variants() { {"su", "pam_authenticate: Authentication failure", loglens::ParserFailureCategory::KnownProgramUnknownMessage, "unrecognized auth pattern: su_other"}, + {"login", "DIALUP AT ttyS0 BY user-d", + loglens::ParserFailureCategory::KnownProgramUnknownMessage, + "unrecognized auth pattern: login_other"}, {"cron", "job completed", loglens::ParserFailureCategory::UnknownProgram, "unrecognized auth pattern: program_cron"},