diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa8e2c..771c115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable user-visible changes should be recorded here. ### Added - Added sanitized golden `report.md` / `report.json` regression fixtures to lock report contracts. +- Added conservative parser coverage for `Accepted publickey` plus selected `pam_faillock` / `pam_sss` variants. ### Changed diff --git a/README.md b/README.md index e41d04c..73428e2 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,16 @@ LogLens currently detects: - One IP trying multiple usernames within 15 minutes - Bursty sudo activity from the same user within 5 minutes -LogLens currently parses and reports these additional auth patterns: - -- `Failed publickey` SSH failures, which count toward SSH brute-force detection by default -- `pam_unix(...:auth): authentication failure` -- `pam_unix(...:session): session opened` - -LogLens also tracks parser coverage telemetry for unsupported or malformed lines, including: +LogLens currently parses and reports these additional auth patterns: + +- `Accepted publickey` SSH successes +- `Failed publickey` SSH failures, which count toward SSH brute-force detection by default +- `pam_unix(...:auth): authentication failure` +- `pam_unix(...:session): session opened` +- selected `pam_faillock(...:auth)` failure variants +- selected `pam_sss(...:auth)` failure variants + +LogLens also tracks parser coverage telemetry for unsupported or malformed lines, including: - `total_lines` - `parsed_lines` @@ -72,13 +75,13 @@ LogLens also tracks parser coverage telemetry for unsupported or malformed lines - `parse_success_rate` - `top_unknown_patterns` -LogLens does not currently detect: - -- Lateral movement -- MFA abuse -- SSH key misuse -- PAM-specific failures beyond the parsed sample patterns -- Cross-file or cross-host correlation +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 +- Cross-file or cross-host correlation ## Build @@ -194,10 +197,10 @@ Tue 2026-03-10 08:31:18 UTC example-host sshd[2245]: Connection closed by authen ## Known Limitations - `syslog_legacy` requires an explicit year; LogLens does not guess one implicitly. -- `journalctl_short_full` currently supports `UTC`, `GMT`, `Z`, and numeric timezone offsets, not arbitrary timezone abbreviations. -- Parser coverage is intentionally narrow and focused on common `sshd`, `sudo`, and `pam_unix` variants. -- Unsupported lines are surfaced as parser telemetry and warnings, not as detector findings. -- `pam_unix` auth failures remain lower-confidence by default unless signal mappings explicitly upgrade them. +- `journalctl_short_full` currently supports `UTC`, `GMT`, `Z`, and numeric timezone offsets, not arbitrary timezone abbreviations. +- Parser coverage is intentionally narrow and focused on common `sshd`, `sudo`, `pam_unix`, and selected `pam_faillock` / `pam_sss` variants. +- Unsupported lines are surfaced as parser telemetry and warnings, not as detector findings. +- `pam_unix` auth failures remain lower-confidence by default unless signal mappings explicitly upgrade them. - Detector configuration uses a fixed `config.json` schema rather than partial overrides or alternate config formats. - Findings are rule-based triage aids, not incident verdicts or attribution. diff --git a/assets/parser_auth_families_journalctl_short_full.log b/assets/parser_auth_families_journalctl_short_full.log new file mode 100644 index 0000000..df00924 --- /dev/null +++ b/assets/parser_auth_families_journalctl_short_full.log @@ -0,0 +1,7 @@ +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: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:54 UTC example-host pam_faillock(sshd:auth): User carol successfully authenticated +Wed 2026-03-11 10:02:25 UTC example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure) +Wed 2026-03-11 10:02:56 UTC example-host pam_sss(sshd:auth): received for user erin: 10 (User not known to the underlying authentication module) +Wed 2026-03-11 10:03:27 UTC example-host pam_sss(sshd:auth): received for user frank: 9 (Authentication service cannot retrieve authentication info) diff --git a/assets/parser_auth_families_syslog.log b/assets/parser_auth_families_syslog.log new file mode 100644 index 0000000..ab745dc --- /dev/null +++ b/assets/parser_auth_families_syslog.log @@ -0,0 +1,7 @@ +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: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:54 example-host pam_faillock(sshd:auth): User carol successfully authenticated +Mar 11 10:02:25 example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure) +Mar 11 10:02:56 example-host pam_sss(sshd:auth): received for user erin: 10 (User not known to the underlying authentication module) +Mar 11 10:03:27 example-host pam_sss(sshd:auth): received for user frank: 9 (Authentication service cannot retrieve authentication info) diff --git a/src/event.hpp b/src/event.hpp index 7f8ad2f..2bd7e92 100644 --- a/src/event.hpp +++ b/src/event.hpp @@ -12,6 +12,7 @@ enum class EventType { Unknown, SshFailedPassword, SshAcceptedPassword, + SshAcceptedPublicKey, SshInvalidUser, SshFailedPublicKey, PamAuthFailure, @@ -37,6 +38,8 @@ inline std::string to_string(EventType type) { return "ssh_failed_password"; case EventType::SshAcceptedPassword: return "ssh_accepted_password"; + case EventType::SshAcceptedPublicKey: + return "ssh_accepted_publickey"; case EventType::SshInvalidUser: return "ssh_invalid_user"; case EventType::SshFailedPublicKey: diff --git a/src/parser.cpp b/src/parser.cpp index 0440fad..04b0b26 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -327,6 +327,24 @@ bool parse_ssh_accepted_message(std::string_view message, Event& event) { return true; } +bool parse_ssh_accepted_publickey_message(std::string_view message, Event& event) { + static constexpr std::string_view accepted_prefix = "Accepted publickey for "; + if (!message.starts_with(accepted_prefix)) { + return false; + } + + auto remaining = message.substr(accepted_prefix.size()); + 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 = EventType::SshAcceptedPublicKey; + return true; +} + bool parse_ssh_failed_publickey_message(std::string_view message, Event& event) { static constexpr std::string_view publickey_prefix = "Failed publickey for "; if (!message.starts_with(publickey_prefix)) { @@ -367,6 +385,25 @@ bool parse_ssh_invalid_user_message(std::string_view message, Event& event) { return true; } +bool parse_pam_named_user_failure_message(std::string_view message, + std::string_view prefix, + Event& event) { + if (!message.starts_with(prefix)) { + return false; + } + + auto remaining = message.substr(prefix.size()); + 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 = EventType::PamAuthFailure; + return true; +} + bool parse_pam_auth_failure_message(std::string_view message, Event& event) { static constexpr std::string_view auth_failure_prefix = "authentication failure;"; if (!message.starts_with(auth_failure_prefix)) { @@ -379,6 +416,30 @@ bool parse_pam_auth_failure_message(std::string_view message, Event& event) { return true; } +bool parse_pam_sss_received_failure_message(std::string_view message, Event& event) { + static constexpr std::string_view received_prefix = "received for user "; + static constexpr std::string_view failure_marker = "(Authentication failure)"; + + if (!message.starts_with(received_prefix) || message.find(failure_marker) == std::string_view::npos) { + return false; + } + + auto remaining = message.substr(received_prefix.size()); + const auto separator = remaining.find(':'); + if (separator == std::string_view::npos) { + return false; + } + + const auto username = trim(remaining.substr(0, separator)); + if (username.empty()) { + return false; + } + + event.username.assign(username); + event.event_type = EventType::PamAuthFailure; + return true; +} + bool parse_session_opened_message(std::string_view message, Event& event) { static constexpr std::string_view session_prefix = "session opened for user "; if (!message.starts_with(session_prefix)) { @@ -423,6 +484,38 @@ bool parse_sudo_message(std::string_view message, Event& event) { return true; } +bool parse_pam_faillock_message(std::string_view message, Event& event) { + if (parse_pam_named_user_failure_message(message, "Consecutive login failures for user ", event)) { + return true; + } + + if (parse_pam_named_user_failure_message(message, "Authentication failure for user ", event)) { + return true; + } + + return false; +} + +std::string classify_unknown_pam_faillock_pattern(std::string_view message) { + if (message.starts_with("User ") && message.find("successfully authenticated") != std::string_view::npos) { + return "pam_faillock_authsucc"; + } + + return "pam_faillock_other"; +} + +std::string classify_unknown_pam_sss_pattern(std::string_view message) { + if (message.find("User not known to the underlying authentication module") != std::string_view::npos) { + return "pam_sss_unknown_user"; + } + + if (message.find("Authentication service cannot retrieve authentication info") != std::string_view::npos) { + return "pam_sss_authinfo_unavail"; + } + + return "pam_sss_other"; +} + std::string classify_unknown_auth_pattern(const Event& event) { const auto message = std::string_view{event.message}; if (event.program == "sshd") { @@ -444,6 +537,14 @@ std::string classify_unknown_auth_pattern(const Event& event) { return "pam_unix_other"; } + if (event.program.starts_with("pam_faillock(")) { + return classify_unknown_pam_faillock_pattern(message); + } + + if (event.program.starts_with("pam_sss(")) { + return classify_unknown_pam_sss_pattern(message); + } + if (event.program == "sudo") { return "sudo_other"; } @@ -460,6 +561,9 @@ bool classify_event(Event& event) { if (parse_ssh_accepted_message(message, event)) { return true; } + if (parse_ssh_accepted_publickey_message(message, event)) { + return true; + } if (parse_ssh_failed_publickey_message(message, event)) { return true; } @@ -479,6 +583,20 @@ bool classify_event(Event& event) { return false; } + if (event.program.starts_with("pam_faillock(")) { + return parse_pam_faillock_message(message, event); + } + + if (event.program.starts_with("pam_sss(")) { + if (parse_pam_auth_failure_message(message, event)) { + return true; + } + if (parse_pam_sss_received_failure_message(message, event)) { + return true; + } + return false; + } + if (event.program == "sudo") { return parse_sudo_message(message, event); } diff --git a/src/report.cpp b/src/report.cpp index 0b0698e..5e71a34 100644 --- a/src/report.cpp +++ b/src/report.cpp @@ -71,6 +71,7 @@ std::vector> build_event_counts(const std::vec std::vector> counts = { {EventType::SshFailedPassword, 0}, {EventType::SshAcceptedPassword, 0}, + {EventType::SshAcceptedPublicKey, 0}, {EventType::SshInvalidUser, 0}, {EventType::SshFailedPublicKey, 0}, {EventType::PamAuthFailure, 0}, diff --git a/src/signal.cpp b/src/signal.cpp index b07cffa..16909ad 100644 --- a/src/signal.cpp +++ b/src/signal.cpp @@ -1,89 +1,90 @@ -#include "signal.hpp" - -#include - -namespace loglens { -namespace { - -struct SignalMapping { - AuthSignalKind signal_kind = AuthSignalKind::Unknown; - bool counts_as_attempt_evidence = false; - bool counts_as_terminal_auth_failure = false; - bool counts_as_sudo_burst_evidence = false; -}; - -std::optional signal_mapping_for_event(const Event& event, const AuthSignalConfig& config) { - switch (event.event_type) { - case EventType::SshFailedPassword: - return SignalMapping{ - AuthSignalKind::SshFailedPassword, - config.ssh_failed_password.counts_as_attempt_evidence, - config.ssh_failed_password.counts_as_terminal_auth_failure, - false}; - case EventType::SshInvalidUser: - return SignalMapping{ - AuthSignalKind::SshInvalidUser, - config.ssh_invalid_user.counts_as_attempt_evidence, - config.ssh_invalid_user.counts_as_terminal_auth_failure, - false}; - case EventType::SshFailedPublicKey: - return SignalMapping{ - AuthSignalKind::SshFailedPublicKey, - config.ssh_failed_publickey.counts_as_attempt_evidence, - config.ssh_failed_publickey.counts_as_terminal_auth_failure, - false}; - case EventType::PamAuthFailure: - return SignalMapping{ - AuthSignalKind::PamAuthFailure, - config.pam_auth_failure.counts_as_attempt_evidence, - config.pam_auth_failure.counts_as_terminal_auth_failure, - false}; - case EventType::SudoCommand: - return SignalMapping{ - AuthSignalKind::SudoCommand, - false, - false, - true}; - case EventType::SessionOpened: - if (event.program == "pam_unix(sudo:session)") { - return SignalMapping{ - AuthSignalKind::SudoSessionOpened, - false, - false, - false}; - } - return std::nullopt; +#include "signal.hpp" + +#include + +namespace loglens { +namespace { + +struct SignalMapping { + AuthSignalKind signal_kind = AuthSignalKind::Unknown; + bool counts_as_attempt_evidence = false; + bool counts_as_terminal_auth_failure = false; + bool counts_as_sudo_burst_evidence = false; +}; + +std::optional signal_mapping_for_event(const Event& event, const AuthSignalConfig& config) { + switch (event.event_type) { + case EventType::SshFailedPassword: + return SignalMapping{ + AuthSignalKind::SshFailedPassword, + config.ssh_failed_password.counts_as_attempt_evidence, + config.ssh_failed_password.counts_as_terminal_auth_failure, + false}; + case EventType::SshInvalidUser: + return SignalMapping{ + AuthSignalKind::SshInvalidUser, + config.ssh_invalid_user.counts_as_attempt_evidence, + config.ssh_invalid_user.counts_as_terminal_auth_failure, + false}; + case EventType::SshFailedPublicKey: + return SignalMapping{ + AuthSignalKind::SshFailedPublicKey, + config.ssh_failed_publickey.counts_as_attempt_evidence, + config.ssh_failed_publickey.counts_as_terminal_auth_failure, + false}; + case EventType::PamAuthFailure: + return SignalMapping{ + AuthSignalKind::PamAuthFailure, + config.pam_auth_failure.counts_as_attempt_evidence, + config.pam_auth_failure.counts_as_terminal_auth_failure, + false}; + case EventType::SudoCommand: + return SignalMapping{ + AuthSignalKind::SudoCommand, + false, + false, + true}; + case EventType::SessionOpened: + if (event.program == "pam_unix(sudo:session)") { + return SignalMapping{ + AuthSignalKind::SudoSessionOpened, + false, + false, + false}; + } + return std::nullopt; case EventType::Unknown: case EventType::SshAcceptedPassword: + case EventType::SshAcceptedPublicKey: default: return std::nullopt; } } - -} // namespace - -std::vector build_auth_signals(const std::vector& events, const AuthSignalConfig& config) { - std::vector signals; - signals.reserve(events.size()); - - for (const auto& event : events) { - const auto mapping = signal_mapping_for_event(event, config); - if (!mapping.has_value()) { - continue; - } - - signals.push_back(AuthSignal{ - event.timestamp, - event.source_ip, - event.username, - mapping->signal_kind, - mapping->counts_as_attempt_evidence, - mapping->counts_as_terminal_auth_failure, - mapping->counts_as_sudo_burst_evidence, - event.line_number}); - } - - return signals; -} - -} // namespace loglens + +} // namespace + +std::vector build_auth_signals(const std::vector& events, const AuthSignalConfig& config) { + std::vector signals; + signals.reserve(events.size()); + + for (const auto& event : events) { + const auto mapping = signal_mapping_for_event(event, config); + if (!mapping.has_value()) { + continue; + } + + signals.push_back(AuthSignal{ + event.timestamp, + event.source_ip, + event.username, + mapping->signal_kind, + mapping->counts_as_attempt_evidence, + mapping->counts_as_terminal_auth_failure, + mapping->counts_as_sudo_burst_evidence, + event.line_number}); + } + + return signals; +} + +} // namespace loglens diff --git a/tests/test_detector.cpp b/tests/test_detector.cpp index 03c86c5..7bcc49f 100644 --- a/tests/test_detector.cpp +++ b/tests/test_detector.cpp @@ -1,354 +1,378 @@ -#include "config.hpp" -#include "detector.hpp" -#include "parser.hpp" -#include "signal.hpp" - -#include -#include -#include -#include -#include -#include -#include - -namespace { - -void expect(bool condition, const std::string& message) { - if (!condition) { - throw std::runtime_error(message); - } -} - -const loglens::Finding* find_finding(const std::vector& findings, - loglens::FindingType type, - const std::string& subject) { - const auto it = std::find_if(findings.begin(), findings.end(), [&](const loglens::Finding& finding) { - return finding.type == type && finding.subject == subject; - }); - return it == findings.end() ? nullptr : &(*it); -} - -const loglens::AuthSignal* find_signal(const std::vector& signals, - loglens::AuthSignalKind signal_kind) { - const auto it = std::find_if(signals.begin(), signals.end(), [&](const loglens::AuthSignal& signal) { - return signal.signal_kind == signal_kind; - }); - return it == signals.end() ? nullptr : &(*it); -} - -std::size_t count_signals(const std::vector& signals, - loglens::AuthSignalKind signal_kind) { - return static_cast(std::count_if(signals.begin(), signals.end(), [&](const loglens::AuthSignal& signal) { - return signal.signal_kind == signal_kind; - })); -} - -std::vector parse_events(loglens::ParserConfig config, std::string_view input_text) { - const loglens::AuthLogParser parser(config); - std::istringstream input(std::string{input_text}); - return parser.parse_stream(input).events; -} - -loglens::ParserConfig make_syslog_config() { - return loglens::ParserConfig{ - loglens::InputMode::SyslogLegacy, - 2026}; -} - -loglens::ParserConfig make_journalctl_config() { - return loglens::ParserConfig{ - loglens::InputMode::JournalctlShortFull, - std::nullopt}; -} - -std::vector build_events() { - return parse_events( - make_syslog_config(), - "Mar 10 08:11:22 example-host sshd[1234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2\n" - "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" - "Mar 10 08:13:10 example-host sshd[1236]: Failed password for test from 203.0.113.10 port 51040 ssh2\n" - "Mar 10 08:14:44 example-host sshd[1237]: Failed password for guest from 203.0.113.10 port 51050 ssh2\n" - "Mar 10 08:18:05 example-host sshd[1238]: Failed password for invalid user deploy from 203.0.113.10 port 51060 ssh2\n" - "Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh\n" - "Mar 10 08:22:10 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe\n" - "Mar 10 08:24:15 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/vi /etc/ssh/sshd_config\n"); -} - +#include "config.hpp" +#include "detector.hpp" +#include "parser.hpp" +#include "signal.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +const loglens::Finding* find_finding(const std::vector& findings, + loglens::FindingType type, + const std::string& subject) { + const auto it = std::find_if(findings.begin(), findings.end(), [&](const loglens::Finding& finding) { + return finding.type == type && finding.subject == subject; + }); + return it == findings.end() ? nullptr : &(*it); +} + +const loglens::AuthSignal* find_signal(const std::vector& signals, + loglens::AuthSignalKind signal_kind) { + const auto it = std::find_if(signals.begin(), signals.end(), [&](const loglens::AuthSignal& signal) { + return signal.signal_kind == signal_kind; + }); + return it == signals.end() ? nullptr : &(*it); +} + +std::size_t count_signals(const std::vector& signals, + loglens::AuthSignalKind signal_kind) { + return static_cast(std::count_if(signals.begin(), signals.end(), [&](const loglens::AuthSignal& signal) { + return signal.signal_kind == signal_kind; + })); +} + +std::vector parse_events(loglens::ParserConfig config, std::string_view input_text) { + const loglens::AuthLogParser parser(config); + std::istringstream input(std::string{input_text}); + return parser.parse_stream(input).events; +} + +loglens::ParserConfig make_syslog_config() { + return loglens::ParserConfig{ + loglens::InputMode::SyslogLegacy, + 2026}; +} + +loglens::ParserConfig make_journalctl_config() { + return loglens::ParserConfig{ + loglens::InputMode::JournalctlShortFull, + std::nullopt}; +} + +std::vector build_events() { + return parse_events( + make_syslog_config(), + "Mar 10 08:11:22 example-host sshd[1234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2\n" + "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" + "Mar 10 08:13:10 example-host sshd[1236]: Failed password for test from 203.0.113.10 port 51040 ssh2\n" + "Mar 10 08:14:44 example-host sshd[1237]: Failed password for guest from 203.0.113.10 port 51050 ssh2\n" + "Mar 10 08:18:05 example-host sshd[1238]: Failed password for invalid user deploy from 203.0.113.10 port 51060 ssh2\n" + "Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh\n" + "Mar 10 08:22:10 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe\n" + "Mar 10 08:24:15 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/vi /etc/ssh/sshd_config\n"); +} + std::vector build_publickey_bruteforce_candidate_events() { return parse_events( make_syslog_config(), "Mar 10 08:11:22 example-host sshd[1234]: Failed password for root from 203.0.113.10 port 51022 ssh2\n" - "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" - "Mar 10 08:13:10 example-host sshd[1236]: Failed password for root from 203.0.113.10 port 51040 ssh2\n" + "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" + "Mar 10 08:13:10 example-host sshd[1236]: Failed password for root from 203.0.113.10 port 51040 ssh2\n" "Mar 10 08:14:44 example-host sshd[1237]: Failed password for root from 203.0.113.10 port 51050 ssh2\n" "Mar 10 08:18:05 example-host sshd[1238]: Failed publickey for root from 203.0.113.10 port 51060 ssh2\n"); } -std::vector build_pam_bruteforce_candidate_events() { +std::vector build_publickey_success_candidate_events() { return parse_events( make_syslog_config(), "Mar 10 08:11:22 example-host sshd[1234]: Failed password for root from 203.0.113.10 port 51022 ssh2\n" "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" "Mar 10 08:13:10 example-host sshd[1236]: Failed password for root from 203.0.113.10 port 51040 ssh2\n" "Mar 10 08:14:44 example-host sshd[1237]: Failed password for root from 203.0.113.10 port 51050 ssh2\n" - "Mar 10 08:18:05 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.10 user=root\n"); -} - -std::vector build_sudo_signal_candidate_events() { - return parse_events( - make_syslog_config(), - "Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh\n" - "Mar 10 08:21:05 example-host pam_unix(sudo:session): session opened for user root by alice(uid=0)\n" - "Mar 10 08:21:10 example-host pam_unix(sshd:session): session closed for user alice\n"); + "Mar 10 08:18:05 example-host sshd[1238]: Accepted publickey for alice from 203.0.113.10 port 51060 ssh2: ED25519 SHA256:SANITIZEDKEY\n"); } -std::vector build_sudo_burst_preservation_events() { +std::vector build_pam_bruteforce_candidate_events() { return parse_events( make_syslog_config(), - "Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh\n" - "Mar 10 08:21:05 example-host pam_unix(sudo:session): session opened for user root by alice(uid=0)\n" - "Mar 10 08:22:10 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe\n" - "Mar 10 08:24:15 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/vi /etc/ssh/sshd_config\n"); -} - -void test_default_thresholds() { - const auto events = build_events(); - const loglens::Detector detector; - const auto findings = detector.analyze(events); - - expect(findings.size() == 3, "expected three findings"); - - const auto* brute_force = find_finding(findings, loglens::FindingType::BruteForce, "203.0.113.10"); - expect(brute_force != nullptr, "expected brute force finding"); - expect(brute_force->event_count == 5, "expected brute force count"); - - const auto* multi_user = find_finding(findings, loglens::FindingType::MultiUserProbing, "203.0.113.10"); - expect(multi_user != nullptr, "expected multi-user finding"); - expect(multi_user->usernames.size() == 5, "expected five usernames"); - - const auto* sudo = find_finding(findings, loglens::FindingType::SudoBurst, "alice"); - expect(sudo != nullptr, "expected sudo finding"); - expect(sudo->event_count == 3, "expected sudo count"); -} - -void test_custom_thresholds() { - const auto events = build_events(); - loglens::DetectorConfig config; - config.brute_force.threshold = 6; - config.multi_user_probing.threshold = 6; - config.sudo_burst.threshold = 4; - - const loglens::Detector detector(config); - const auto findings = detector.analyze(events); - expect(findings.empty(), "expected custom thresholds to suppress findings"); -} - -void test_auth_signal_defaults() { - const auto events = parse_events( - make_syslog_config(), - "Mar 10 08:18:05 example-host sshd[1238]: Failed publickey for root from 203.0.113.10 port 51060 ssh2\n" - "Mar 10 08:18:06 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.11 user=alice\n"); - - const auto signals = loglens::build_auth_signals(events, loglens::DetectorConfig{}.auth_signal_mappings); - expect(signals.size() == 2, "expected two auth signals"); - - const auto* publickey = find_signal(signals, loglens::AuthSignalKind::SshFailedPublicKey); - expect(publickey != nullptr, "expected publickey signal"); - expect(publickey->counts_as_attempt_evidence, "expected publickey to count as attempt evidence"); - expect(publickey->counts_as_terminal_auth_failure, "expected publickey to count as terminal auth failure"); - - const auto* pam = find_signal(signals, loglens::AuthSignalKind::PamAuthFailure); - expect(pam != nullptr, "expected pam auth signal"); - expect(pam->counts_as_attempt_evidence, "expected pam auth failure to count as attempt evidence"); - expect(!pam->counts_as_terminal_auth_failure, "expected pam auth failure to stay non-terminal by default"); -} - + "Mar 10 08:11:22 example-host sshd[1234]: Failed password for root from 203.0.113.10 port 51022 ssh2\n" + "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" + "Mar 10 08:13:10 example-host sshd[1236]: Failed password for root from 203.0.113.10 port 51040 ssh2\n" + "Mar 10 08:14:44 example-host sshd[1237]: Failed password for root from 203.0.113.10 port 51050 ssh2\n" + "Mar 10 08:18:05 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.10 user=root\n"); +} + +std::vector build_sudo_signal_candidate_events() { + return parse_events( + make_syslog_config(), + "Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh\n" + "Mar 10 08:21:05 example-host pam_unix(sudo:session): session opened for user root by alice(uid=0)\n" + "Mar 10 08:21:10 example-host pam_unix(sshd:session): session closed for user alice\n"); +} + +std::vector build_sudo_burst_preservation_events() { + return parse_events( + make_syslog_config(), + "Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh\n" + "Mar 10 08:21:05 example-host pam_unix(sudo:session): session opened for user root by alice(uid=0)\n" + "Mar 10 08:22:10 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe\n" + "Mar 10 08:24:15 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/vi /etc/ssh/sshd_config\n"); +} + +void test_default_thresholds() { + const auto events = build_events(); + const loglens::Detector detector; + const auto findings = detector.analyze(events); + + expect(findings.size() == 3, "expected three findings"); + + const auto* brute_force = find_finding(findings, loglens::FindingType::BruteForce, "203.0.113.10"); + expect(brute_force != nullptr, "expected brute force finding"); + expect(brute_force->event_count == 5, "expected brute force count"); + + const auto* multi_user = find_finding(findings, loglens::FindingType::MultiUserProbing, "203.0.113.10"); + expect(multi_user != nullptr, "expected multi-user finding"); + expect(multi_user->usernames.size() == 5, "expected five usernames"); + + const auto* sudo = find_finding(findings, loglens::FindingType::SudoBurst, "alice"); + expect(sudo != nullptr, "expected sudo finding"); + expect(sudo->event_count == 3, "expected sudo count"); +} + +void test_custom_thresholds() { + const auto events = build_events(); + loglens::DetectorConfig config; + config.brute_force.threshold = 6; + config.multi_user_probing.threshold = 6; + config.sudo_burst.threshold = 4; + + const loglens::Detector detector(config); + const auto findings = detector.analyze(events); + expect(findings.empty(), "expected custom thresholds to suppress findings"); +} + +void test_auth_signal_defaults() { + const auto events = parse_events( + make_syslog_config(), + "Mar 10 08:18:05 example-host sshd[1238]: Failed publickey for root from 203.0.113.10 port 51060 ssh2\n" + "Mar 10 08:18:06 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.11 user=alice\n"); + + const auto signals = loglens::build_auth_signals(events, loglens::DetectorConfig{}.auth_signal_mappings); + expect(signals.size() == 2, "expected two auth signals"); + + const auto* publickey = find_signal(signals, loglens::AuthSignalKind::SshFailedPublicKey); + expect(publickey != nullptr, "expected publickey signal"); + expect(publickey->counts_as_attempt_evidence, "expected publickey to count as attempt evidence"); + expect(publickey->counts_as_terminal_auth_failure, "expected publickey to count as terminal auth failure"); + + const auto* pam = find_signal(signals, loglens::AuthSignalKind::PamAuthFailure); + expect(pam != nullptr, "expected pam auth signal"); + expect(pam->counts_as_attempt_evidence, "expected pam auth failure to count as attempt evidence"); + expect(!pam->counts_as_terminal_auth_failure, "expected pam auth failure to stay non-terminal by default"); +} + void test_failed_publickey_contributes_to_bruteforce_by_default() { const auto events = build_publickey_bruteforce_candidate_events(); const loglens::Detector detector; const auto findings = detector.analyze(events); - const auto* brute_force = find_finding(findings, loglens::FindingType::BruteForce, "203.0.113.10"); + const auto* brute_force = find_finding(findings, loglens::FindingType::BruteForce, "203.0.113.10"); expect(brute_force != nullptr, "expected publickey evidence to contribute to brute force"); expect(brute_force->event_count == 5, "expected publickey evidence to raise brute force count to five"); } -void test_sudo_signals_include_command_and_session_opened() { - const auto events = build_sudo_signal_candidate_events(); +void test_accepted_publickey_success_stays_out_of_failure_signals() { + const auto events = build_publickey_success_candidate_events(); const auto signals = loglens::build_auth_signals(events, loglens::DetectorConfig{}.auth_signal_mappings); - expect(signals.size() == 2, "expected sudo command and supported sudo session-opened signals only"); - expect(count_signals(signals, loglens::AuthSignalKind::SudoCommand) == 1, - "expected one sudo command signal"); - expect(count_signals(signals, loglens::AuthSignalKind::SudoSessionOpened) == 1, - "expected one sudo session-opened signal"); - - const auto* sudo_command = find_signal(signals, loglens::AuthSignalKind::SudoCommand); - expect(sudo_command != nullptr, "expected sudo command signal"); - expect(sudo_command->counts_as_sudo_burst_evidence, - "expected sudo command signal to count toward sudo burst evidence"); - expect(!sudo_command->counts_as_attempt_evidence, "did not expect sudo command to count as auth attempt evidence"); - expect(!sudo_command->counts_as_terminal_auth_failure, - "did not expect sudo command to count as terminal auth failure"); + expect(signals.size() == 4, "expected accepted publickey success to stay out of the signal layer"); - const auto* sudo_session = find_signal(signals, loglens::AuthSignalKind::SudoSessionOpened); - expect(sudo_session != nullptr, "expected sudo session-opened signal"); - expect(!sudo_session->counts_as_sudo_burst_evidence, - "expected sudo session-opened signal to stay out of sudo burst counting by default"); - expect(!sudo_session->counts_as_attempt_evidence, - "did not expect sudo session-opened to count as auth attempt evidence"); - expect(!sudo_session->counts_as_terminal_auth_failure, - "did not expect sudo session-opened to count as terminal auth failure"); -} - -void test_sudo_burst_behavior_is_preserved_with_signal_layer() { - const auto events = build_sudo_burst_preservation_events(); const loglens::Detector detector; const auto findings = detector.analyze(events); - - const auto* sudo = find_finding(findings, loglens::FindingType::SudoBurst, "alice"); - expect(sudo != nullptr, "expected sudo burst finding"); - expect(sudo->event_count == 3, - "expected sudo burst count to remain based on command events rather than session-opened lines"); -} - -void test_unsupported_pam_session_close_remains_telemetry_only() { - const loglens::AuthLogParser parser(make_syslog_config()); - std::istringstream input( - "Mar 10 09:06:10 example-host pam_unix(sudo:session): session closed for user alice\n"); - - const auto result = parser.parse_stream(input); - expect(result.events.empty(), "expected unsupported session-close line to stay out of parsed events"); - expect(result.warnings.size() == 1, "expected unsupported session-close line to produce one warning"); - expect(result.quality.top_unknown_patterns.size() == 1, "expected one unknown pattern bucket"); - expect(result.quality.top_unknown_patterns.front().pattern == "pam_unix_other", - "expected unsupported session-close line to remain in pam_unix_other telemetry"); - - const auto signals = loglens::build_auth_signals(result.events, loglens::DetectorConfig{}.auth_signal_mappings); - expect(signals.empty(), "expected unsupported session-close line to stay out of the signal layer"); -} - -void test_pam_auth_failure_does_not_trigger_bruteforce_by_default() { - const auto events = build_pam_bruteforce_candidate_events(); - const loglens::Detector detector; - const auto findings = detector.analyze(events); - const auto* brute_force = find_finding(findings, loglens::FindingType::BruteForce, "203.0.113.10"); - expect(brute_force == nullptr, "expected pam auth failure to stay out of brute force by default"); + expect(brute_force == nullptr, + "expected accepted publickey success to stay out of brute-force counting"); } -void test_equivalent_attack_scenario_yields_same_finding_count_across_modes() { - const auto syslog_events = parse_events( - make_syslog_config(), - "Mar 10 08:11:22 example-host sshd[1234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2\n" - "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" - "Mar 10 08:13:10 example-host sshd[1236]: Failed password for test from 203.0.113.10 port 51040 ssh2\n" - "Mar 10 08:14:44 example-host sshd[1237]: Failed password for guest from 203.0.113.10 port 51050 ssh2\n" - "Mar 10 08:18:05 example-host sshd[1238]: Failed publickey for invalid user deploy from 203.0.113.10 port 51060 ssh2\n"); - - const auto journalctl_events = parse_events( - make_journalctl_config(), - "Tue 2026-03-10 08:11:22 UTC example-host sshd[2234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2\n" - "Tue 2026-03-10 08:12:05 UTC example-host sshd[2235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" - "Tue 2026-03-10 08:13:10 UTC example-host sshd[2236]: Failed password for test from 203.0.113.10 port 51040 ssh\n" - "Tue 2026-03-10 08:14:44 UTC example-host sshd[2237]: Failed password for guest from 203.0.113.10 port 51050 ssh2\n" - "Tue 2026-03-10 08:18:05 UTC example-host sshd[2238]: Failed publickey for invalid user deploy from 203.0.113.10 port 51060 ssh2\n"); - - const loglens::Detector detector; - const auto syslog_findings = detector.analyze(syslog_events); - const auto journalctl_findings = detector.analyze(journalctl_events); - - expect(syslog_findings.size() == journalctl_findings.size(), - "expected equivalent scenarios to yield the same finding count across modes"); - expect(find_finding(syslog_findings, loglens::FindingType::BruteForce, "203.0.113.10") != nullptr, - "expected syslog brute force finding"); - expect(find_finding(journalctl_findings, loglens::FindingType::BruteForce, "203.0.113.10") != nullptr, - "expected journalctl brute force finding"); -} - -void test_load_valid_config() { - const auto temp_path = std::filesystem::current_path() / "valid_config_test.json"; - { - std::ofstream output(temp_path); - output << "{\n" - << " \"input_mode\": \"syslog_legacy\",\n" - << " \"timestamp\": { \"assume_year\": 2026 },\n" - << " \"brute_force\": { \"threshold\": 5, \"window_minutes\": 10 },\n" - << " \"multi_user_probing\": { \"threshold\": 3, \"window_minutes\": 15 },\n" - << " \"sudo_burst\": { \"threshold\": 3, \"window_minutes\": 5 },\n" - << " \"auth_signal_mappings\": {\n" - << " \"ssh_failed_password\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" - << " \"ssh_invalid_user\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" - << " \"ssh_failed_publickey\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" - << " \"pam_auth_failure\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": false }\n" - << " }\n" - << "}\n"; - } - - const auto config = loglens::load_app_config(temp_path); - std::filesystem::remove(temp_path); - - expect(config.input_mode == loglens::InputMode::SyslogLegacy, "expected input mode from config"); - expect(config.timestamp.assume_year == 2026, "expected assume_year from config"); - expect(config.detector.brute_force.threshold == 5, "expected brute force threshold from config"); - expect(config.detector.auth_signal_mappings.ssh_failed_publickey.counts_as_terminal_auth_failure, - "expected publickey mapping from config"); - expect(!config.detector.auth_signal_mappings.pam_auth_failure.counts_as_terminal_auth_failure, - "expected pam auth mapping from config"); - - const auto events = build_events(); - const loglens::Detector detector(config.detector); - const auto findings = detector.analyze(events); - expect(findings.size() == 3, "expected loaded config to preserve default findings"); -} - -void test_reject_invalid_config() { - const auto temp_path = std::filesystem::current_path() / "invalid_config_test.json"; - { - std::ofstream output(temp_path); - output << "{\n" - << " \"input_mode\": \"syslog_legacy\",\n" - << " \"timestamp\": { \"assume_year\": \"bad\" },\n" - << " \"brute_force\": { \"threshold\": 5, \"window_minutes\": 10 },\n" - << " \"multi_user_probing\": { \"threshold\": 3, \"window_minutes\": 15 },\n" - << " \"sudo_burst\": { \"threshold\": 3, \"window_minutes\": 5 },\n" - << " \"auth_signal_mappings\": {\n" - << " \"ssh_failed_password\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" - << " \"ssh_invalid_user\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" - << " \"ssh_failed_publickey\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" - << " \"pam_auth_failure\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": false }\n" - << " }\n" - << "}\n"; - } - - bool threw = false; - std::string message; - try { - static_cast(loglens::load_app_config(temp_path)); - } catch (const std::runtime_error& error) { - threw = true; - message = error.what(); - } - - std::filesystem::remove(temp_path); - expect(threw, "expected invalid config to be rejected"); - expect(message.find("assume_year") != std::string::npos, - "expected invalid config error to mention assume_year"); -} - -} // namespace - +void test_sudo_signals_include_command_and_session_opened() { + const auto events = build_sudo_signal_candidate_events(); + const auto signals = loglens::build_auth_signals(events, loglens::DetectorConfig{}.auth_signal_mappings); + + expect(signals.size() == 2, "expected sudo command and supported sudo session-opened signals only"); + expect(count_signals(signals, loglens::AuthSignalKind::SudoCommand) == 1, + "expected one sudo command signal"); + expect(count_signals(signals, loglens::AuthSignalKind::SudoSessionOpened) == 1, + "expected one sudo session-opened signal"); + + const auto* sudo_command = find_signal(signals, loglens::AuthSignalKind::SudoCommand); + expect(sudo_command != nullptr, "expected sudo command signal"); + expect(sudo_command->counts_as_sudo_burst_evidence, + "expected sudo command signal to count toward sudo burst evidence"); + expect(!sudo_command->counts_as_attempt_evidence, "did not expect sudo command to count as auth attempt evidence"); + expect(!sudo_command->counts_as_terminal_auth_failure, + "did not expect sudo command to count as terminal auth failure"); + + const auto* sudo_session = find_signal(signals, loglens::AuthSignalKind::SudoSessionOpened); + expect(sudo_session != nullptr, "expected sudo session-opened signal"); + expect(!sudo_session->counts_as_sudo_burst_evidence, + "expected sudo session-opened signal to stay out of sudo burst counting by default"); + expect(!sudo_session->counts_as_attempt_evidence, + "did not expect sudo session-opened to count as auth attempt evidence"); + expect(!sudo_session->counts_as_terminal_auth_failure, + "did not expect sudo session-opened to count as terminal auth failure"); +} + +void test_sudo_burst_behavior_is_preserved_with_signal_layer() { + const auto events = build_sudo_burst_preservation_events(); + const loglens::Detector detector; + const auto findings = detector.analyze(events); + + const auto* sudo = find_finding(findings, loglens::FindingType::SudoBurst, "alice"); + expect(sudo != nullptr, "expected sudo burst finding"); + expect(sudo->event_count == 3, + "expected sudo burst count to remain based on command events rather than session-opened lines"); +} + +void test_unsupported_pam_session_close_remains_telemetry_only() { + const loglens::AuthLogParser parser(make_syslog_config()); + std::istringstream input( + "Mar 10 09:06:10 example-host pam_unix(sudo:session): session closed for user alice\n"); + + const auto result = parser.parse_stream(input); + expect(result.events.empty(), "expected unsupported session-close line to stay out of parsed events"); + expect(result.warnings.size() == 1, "expected unsupported session-close line to produce one warning"); + expect(result.quality.top_unknown_patterns.size() == 1, "expected one unknown pattern bucket"); + expect(result.quality.top_unknown_patterns.front().pattern == "pam_unix_other", + "expected unsupported session-close line to remain in pam_unix_other telemetry"); + + const auto signals = loglens::build_auth_signals(result.events, loglens::DetectorConfig{}.auth_signal_mappings); + expect(signals.empty(), "expected unsupported session-close line to stay out of the signal layer"); +} + +void test_pam_auth_failure_does_not_trigger_bruteforce_by_default() { + const auto events = build_pam_bruteforce_candidate_events(); + const loglens::Detector detector; + const auto findings = detector.analyze(events); + + const auto* brute_force = find_finding(findings, loglens::FindingType::BruteForce, "203.0.113.10"); + expect(brute_force == nullptr, "expected pam auth failure to stay out of brute force by default"); +} + +void test_equivalent_attack_scenario_yields_same_finding_count_across_modes() { + const auto syslog_events = parse_events( + make_syslog_config(), + "Mar 10 08:11:22 example-host sshd[1234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2\n" + "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" + "Mar 10 08:13:10 example-host sshd[1236]: Failed password for test from 203.0.113.10 port 51040 ssh2\n" + "Mar 10 08:14:44 example-host sshd[1237]: Failed password for guest from 203.0.113.10 port 51050 ssh2\n" + "Mar 10 08:18:05 example-host sshd[1238]: Failed publickey for invalid user deploy from 203.0.113.10 port 51060 ssh2\n"); + + const auto journalctl_events = parse_events( + make_journalctl_config(), + "Tue 2026-03-10 08:11:22 UTC example-host sshd[2234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2\n" + "Tue 2026-03-10 08:12:05 UTC example-host sshd[2235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" + "Tue 2026-03-10 08:13:10 UTC example-host sshd[2236]: Failed password for test from 203.0.113.10 port 51040 ssh\n" + "Tue 2026-03-10 08:14:44 UTC example-host sshd[2237]: Failed password for guest from 203.0.113.10 port 51050 ssh2\n" + "Tue 2026-03-10 08:18:05 UTC example-host sshd[2238]: Failed publickey for invalid user deploy from 203.0.113.10 port 51060 ssh2\n"); + + const loglens::Detector detector; + const auto syslog_findings = detector.analyze(syslog_events); + const auto journalctl_findings = detector.analyze(journalctl_events); + + expect(syslog_findings.size() == journalctl_findings.size(), + "expected equivalent scenarios to yield the same finding count across modes"); + expect(find_finding(syslog_findings, loglens::FindingType::BruteForce, "203.0.113.10") != nullptr, + "expected syslog brute force finding"); + expect(find_finding(journalctl_findings, loglens::FindingType::BruteForce, "203.0.113.10") != nullptr, + "expected journalctl brute force finding"); +} + +void test_load_valid_config() { + const auto temp_path = std::filesystem::current_path() / "valid_config_test.json"; + { + std::ofstream output(temp_path); + output << "{\n" + << " \"input_mode\": \"syslog_legacy\",\n" + << " \"timestamp\": { \"assume_year\": 2026 },\n" + << " \"brute_force\": { \"threshold\": 5, \"window_minutes\": 10 },\n" + << " \"multi_user_probing\": { \"threshold\": 3, \"window_minutes\": 15 },\n" + << " \"sudo_burst\": { \"threshold\": 3, \"window_minutes\": 5 },\n" + << " \"auth_signal_mappings\": {\n" + << " \"ssh_failed_password\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" + << " \"ssh_invalid_user\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" + << " \"ssh_failed_publickey\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" + << " \"pam_auth_failure\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": false }\n" + << " }\n" + << "}\n"; + } + + const auto config = loglens::load_app_config(temp_path); + std::filesystem::remove(temp_path); + + expect(config.input_mode == loglens::InputMode::SyslogLegacy, "expected input mode from config"); + expect(config.timestamp.assume_year == 2026, "expected assume_year from config"); + expect(config.detector.brute_force.threshold == 5, "expected brute force threshold from config"); + expect(config.detector.auth_signal_mappings.ssh_failed_publickey.counts_as_terminal_auth_failure, + "expected publickey mapping from config"); + expect(!config.detector.auth_signal_mappings.pam_auth_failure.counts_as_terminal_auth_failure, + "expected pam auth mapping from config"); + + const auto events = build_events(); + const loglens::Detector detector(config.detector); + const auto findings = detector.analyze(events); + expect(findings.size() == 3, "expected loaded config to preserve default findings"); +} + +void test_reject_invalid_config() { + const auto temp_path = std::filesystem::current_path() / "invalid_config_test.json"; + { + std::ofstream output(temp_path); + output << "{\n" + << " \"input_mode\": \"syslog_legacy\",\n" + << " \"timestamp\": { \"assume_year\": \"bad\" },\n" + << " \"brute_force\": { \"threshold\": 5, \"window_minutes\": 10 },\n" + << " \"multi_user_probing\": { \"threshold\": 3, \"window_minutes\": 15 },\n" + << " \"sudo_burst\": { \"threshold\": 3, \"window_minutes\": 5 },\n" + << " \"auth_signal_mappings\": {\n" + << " \"ssh_failed_password\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" + << " \"ssh_invalid_user\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" + << " \"ssh_failed_publickey\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": true },\n" + << " \"pam_auth_failure\": { \"counts_as_attempt_evidence\": true, \"counts_as_terminal_auth_failure\": false }\n" + << " }\n" + << "}\n"; + } + + bool threw = false; + std::string message; + try { + static_cast(loglens::load_app_config(temp_path)); + } catch (const std::runtime_error& error) { + threw = true; + message = error.what(); + } + + std::filesystem::remove(temp_path); + expect(threw, "expected invalid config to be rejected"); + expect(message.find("assume_year") != std::string::npos, + "expected invalid config error to mention assume_year"); +} + +} // namespace + int main() { test_default_thresholds(); test_custom_thresholds(); test_auth_signal_defaults(); test_failed_publickey_contributes_to_bruteforce_by_default(); + test_accepted_publickey_success_stays_out_of_failure_signals(); test_sudo_signals_include_command_and_session_opened(); test_sudo_burst_behavior_is_preserved_with_signal_layer(); - test_unsupported_pam_session_close_remains_telemetry_only(); - test_pam_auth_failure_does_not_trigger_bruteforce_by_default(); - test_equivalent_attack_scenario_yields_same_finding_count_across_modes(); - test_load_valid_config(); - test_reject_invalid_config(); - return 0; -} + test_unsupported_pam_session_close_remains_telemetry_only(); + test_pam_auth_failure_does_not_trigger_bruteforce_by_default(); + test_equivalent_attack_scenario_yields_same_finding_count_across_modes(); + test_load_valid_config(); + test_reject_invalid_config(); + return 0; +} diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index d4eca9e..bb4ae91 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -1,135 +1,167 @@ -#include "parser.hpp" - -#include -#include -#include -#include -#include -#include - -namespace { - -void expect(bool condition, const std::string& message) { - if (!condition) { - throw std::runtime_error(message); - } -} - +#include "parser.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + loglens::AuthLogParser make_syslog_parser() { return loglens::AuthLogParser(loglens::ParserConfig{ loglens::InputMode::SyslogLegacy, 2026}); } -std::filesystem::path repo_root() { - const std::filesystem::path source_path{__FILE__}; - std::vector candidates; - - if (source_path.is_absolute()) { - candidates.push_back(source_path); - } else { - const auto cwd = std::filesystem::current_path(); - candidates.push_back(cwd / source_path); - candidates.push_back(cwd.parent_path() / source_path); - } - - for (const auto& candidate : candidates) { - if (std::filesystem::exists(candidate)) { - return candidate.parent_path().parent_path(); - } - } - - throw std::runtime_error("unable to resolve repository root from test source path"); -} - -std::filesystem::path asset_path(std::string_view filename) { - return repo_root() / "assets" / std::string(filename); -} - -void expect_close(double actual, double expected, double tolerance, const std::string& message) { - if (std::fabs(actual - expected) > tolerance) { - throw std::runtime_error(message); - } -} - -void test_invalid_user_failure() { - const auto parser = make_syslog_parser(); - std::string error; - const auto event = parser.parse_line( - "Mar 10 08:11:22 example-host sshd[1234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2", - 1, - &error); - - expect(event.has_value(), "expected invalid-user failure event"); - expect(error.empty(), "expected empty parse error"); - expect(event->program == "sshd", "expected sshd program"); - expect(event->pid.has_value() && *event->pid == 1234, "expected parsed pid"); - expect(event->hostname == "example-host", "expected hostname"); - expect(event->username == "admin", "expected parsed username"); - expect(event->source_ip == "203.0.113.10", "expected parsed source ip"); - expect(event->event_type == loglens::EventType::SshInvalidUser, "expected invalid user type"); - expect(loglens::format_timestamp(event->timestamp) == "2026-03-10 08:11:22", - "expected explicit syslog year injection"); -} - -void test_standard_failure() { - const auto parser = make_syslog_parser(); - const auto event = parser.parse_line( - "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2", - 2); - - expect(event.has_value(), "expected failed password event"); - expect(event->username == "root", "expected root username"); - expect(event->event_type == loglens::EventType::SshFailedPassword, "expected ssh failure type"); +loglens::AuthLogParser make_journalctl_parser() { + return loglens::AuthLogParser(loglens::ParserConfig{ + loglens::InputMode::JournalctlShortFull, + std::nullopt}); } - + +std::filesystem::path repo_root() { + const std::filesystem::path source_path{__FILE__}; + std::vector candidates; + + if (source_path.is_absolute()) { + candidates.push_back(source_path); + } else { + const auto cwd = std::filesystem::current_path(); + candidates.push_back(cwd / source_path); + candidates.push_back(cwd.parent_path() / source_path); + } + + for (const auto& candidate : candidates) { + if (std::filesystem::exists(candidate)) { + return candidate.parent_path().parent_path(); + } + } + + throw std::runtime_error("unable to resolve repository root from test source path"); +} + +std::filesystem::path asset_path(std::string_view filename) { + return repo_root() / "assets" / std::string(filename); +} + +void expect_close(double actual, double expected, double tolerance, const std::string& message) { + if (std::fabs(actual - expected) > tolerance) { + throw std::runtime_error(message); + } +} + +void test_invalid_user_failure() { + const auto parser = make_syslog_parser(); + std::string error; + const auto event = parser.parse_line( + "Mar 10 08:11:22 example-host sshd[1234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2", + 1, + &error); + + expect(event.has_value(), "expected invalid-user failure event"); + expect(error.empty(), "expected empty parse error"); + expect(event->program == "sshd", "expected sshd program"); + expect(event->pid.has_value() && *event->pid == 1234, "expected parsed pid"); + expect(event->hostname == "example-host", "expected hostname"); + expect(event->username == "admin", "expected parsed username"); + expect(event->source_ip == "203.0.113.10", "expected parsed source ip"); + expect(event->event_type == loglens::EventType::SshInvalidUser, "expected invalid user type"); + expect(loglens::format_timestamp(event->timestamp) == "2026-03-10 08:11:22", + "expected explicit syslog year injection"); +} + +void test_standard_failure() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2", + 2); + + expect(event.has_value(), "expected failed password event"); + expect(event->username == "root", "expected root username"); + expect(event->event_type == loglens::EventType::SshFailedPassword, "expected ssh failure type"); +} + void test_success_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( "Mar 10 08:20:10 example-host sshd[1240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2", 3); - - expect(event.has_value(), "expected accepted password event"); - expect(event->username == "alice", "expected alice username"); + + expect(event.has_value(), "expected accepted password event"); + expect(event->username == "alice", "expected alice username"); expect(event->source_ip == "203.0.113.20", "expected alice source ip"); expect(event->event_type == loglens::EventType::SshAcceptedPassword, "expected ssh success type"); } -void test_sudo_event() { +void test_accepted_publickey_success_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( - "Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh", + "Mar 11 10:00:01 example-host sshd[2100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY", 4); - expect(event.has_value(), "expected sudo event"); - expect(event->program == "sudo", "expected sudo program"); - expect(event->username == "alice", "expected sudo username"); - expect(event->event_type == loglens::EventType::SudoCommand, "expected sudo event type"); + expect(event.has_value(), "expected accepted publickey event"); + expect(event->username == "alice", "expected accepted publickey username"); + expect(event->source_ip == "203.0.113.70", "expected accepted publickey source ip"); + expect(event->event_type == loglens::EventType::SshAcceptedPublicKey, + "expected accepted publickey event type"); } -void test_failed_publickey_event() { +void test_sudo_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( - "Mar 10 08:27:10 example-host sshd[1243]: Failed publickey for invalid user svc-backup from 203.0.113.40 port 51240 ssh2", - 5); - - expect(event.has_value(), "expected failed publickey event"); - expect(event->username == "svc-backup", "expected parsed publickey username"); - expect(event->source_ip == "203.0.113.40", "expected parsed publickey source ip"); - expect(event->event_type == loglens::EventType::SshFailedPublicKey, "expected ssh publickey type"); + "Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh", + 4); + + expect(event.has_value(), "expected sudo event"); + expect(event->program == "sudo", "expected sudo program"); + expect(event->username == "alice", "expected sudo username"); + expect(event->event_type == loglens::EventType::SudoCommand, "expected sudo event type"); +} + +void test_failed_publickey_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:27:10 example-host sshd[1243]: Failed publickey for invalid user svc-backup from 203.0.113.40 port 51240 ssh2", + 5); + + expect(event.has_value(), "expected failed publickey event"); + expect(event->username == "svc-backup", "expected parsed publickey username"); + expect(event->source_ip == "203.0.113.40", "expected parsed publickey source ip"); + expect(event->event_type == loglens::EventType::SshFailedPublicKey, "expected ssh publickey type"); +} + +void test_pam_auth_failure_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:28:33 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.41 user=alice", + 6); + + expect(event.has_value(), "expected pam auth failure event"); + expect(event->program == "pam_unix(sshd:auth)", "expected pam_unix auth program"); + expect(event->username == "alice", "expected pam auth username"); + expect(event->source_ip == "203.0.113.41", "expected pam auth source ip"); + expect(event->event_type == loglens::EventType::PamAuthFailure, "expected pam auth failure type"); } -void test_pam_auth_failure_event() { +void test_pam_sss_received_failure_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( - "Mar 10 08:28:33 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.41 user=alice", - 6); + "Mar 11 10:02:25 example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure)", + 7); - expect(event.has_value(), "expected pam auth failure event"); - expect(event->program == "pam_unix(sshd:auth)", "expected pam_unix auth program"); - expect(event->username == "alice", "expected pam auth username"); - expect(event->source_ip == "203.0.113.41", "expected pam auth source ip"); - expect(event->event_type == loglens::EventType::PamAuthFailure, "expected pam auth failure type"); + expect(event.has_value(), "expected pam_sss received failure event"); + expect(event->program == "pam_sss(sshd:auth)", "expected pam_sss auth program"); + expect(event->username == "dave", "expected pam_sss username"); + expect(event->source_ip.empty(), "expected pam_sss received failure to stay source-less"); + expect(event->event_type == loglens::EventType::PamAuthFailure, "expected pam_sss failure type"); } void test_session_opened_event() { @@ -137,188 +169,267 @@ void test_session_opened_event() { const auto event = parser.parse_line( "Mar 10 08:29:50 example-host pam_unix(sudo:session): session opened for user root by alice(uid=0)", 7); - - expect(event.has_value(), "expected session opened event"); - expect(event->program == "pam_unix(sudo:session)", "expected pam_unix session program"); - expect(event->username == "alice", "expected session actor username"); - expect(event->source_ip.empty(), "expected session opened to have no source ip"); - expect(event->event_type == loglens::EventType::SessionOpened, "expected session opened type"); -} - -void test_journalctl_short_full_event() { - const loglens::AuthLogParser parser(loglens::ParserConfig{ - loglens::InputMode::JournalctlShortFull, - std::nullopt}); - const auto event = parser.parse_line( - "Tue 2026-03-10 08:11:22 UTC example-host sshd[2234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2", - 8); - - expect(event.has_value(), "expected journalctl short-full event"); - expect(event->hostname == "example-host", "expected journalctl hostname"); - expect(event->username == "admin", "expected journalctl username"); - expect(event->event_type == loglens::EventType::SshInvalidUser, "expected journalctl event classification"); - expect(loglens::format_timestamp(event->timestamp) == "2026-03-10 08:11:22", + + expect(event.has_value(), "expected session opened event"); + expect(event->program == "pam_unix(sudo:session)", "expected pam_unix session program"); + expect(event->username == "alice", "expected session actor username"); + expect(event->source_ip.empty(), "expected session opened to have no source ip"); + expect(event->event_type == loglens::EventType::SessionOpened, "expected session opened type"); +} + +void test_journalctl_short_full_event() { + const loglens::AuthLogParser parser(loglens::ParserConfig{ + loglens::InputMode::JournalctlShortFull, + std::nullopt}); + const auto event = parser.parse_line( + "Tue 2026-03-10 08:11:22 UTC example-host sshd[2234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2", + 8); + + expect(event.has_value(), "expected journalctl short-full event"); + expect(event->hostname == "example-host", "expected journalctl hostname"); + expect(event->username == "admin", "expected journalctl username"); + expect(event->event_type == loglens::EventType::SshInvalidUser, "expected journalctl event classification"); + expect(loglens::format_timestamp(event->timestamp) == "2026-03-10 08:11:22", "expected journalctl timestamp to preserve embedded year and timezone"); } -void test_malformed_line() { - const auto parser = make_syslog_parser(); - std::string error; - const auto event = parser.parse_line("malformed log line without syslog header", 9, &error); - - expect(!event.has_value(), "expected malformed line to fail"); - expect(!error.empty(), "expected parse error for malformed line"); -} - -void test_unknown_auth_patterns_are_warnings_only() { +void test_syslog_auth_family_fixture_file() { const auto parser = make_syslog_parser(); - std::istringstream input( - "Mar 10 08:11:22 example-host sshd[1234]: Failed password for root from 203.0.113.10 port 51022 ssh2\n" - "Mar 10 08:12:05 example-host sshd[1235]: Failed publickey for invalid user svc-backup from 203.0.113.10 port 51030 ssh2\n" - "Mar 10 08:13:10 example-host sshd[1236]: Connection closed by authenticating user alice 203.0.113.50 port 51290 [preauth]\n" - "Mar 10 08:14:44 example-host sshd[1237]: Timeout, client not responding from 203.0.113.51 port 51291\n"); - - const auto result = parser.parse_stream(input); - expect(result.events.size() == 2, "expected only recognized lines to become events"); - expect(result.warnings.size() == 2, "expected unknown auth patterns to become warnings"); - expect(result.quality.total_lines == 4, "expected total analyzed line count"); - expect(result.quality.parsed_lines == 2, "expected parsed line count"); - expect(result.quality.unparsed_lines == 2, "expected unparsed line count"); - expect(result.quality.parse_success_rate == 0.5, "expected parse success rate"); - expect(result.quality.top_unknown_patterns.size() == 2, "expected two unknown pattern buckets"); - expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", - "expected preauth connection close pattern"); - expect(result.quality.top_unknown_patterns[0].count == 1, "expected preauth connection close count"); - expect(result.quality.top_unknown_patterns[1].pattern == "sshd_timeout_or_disconnection", - "expected timeout/disconnection pattern"); - expect(result.quality.top_unknown_patterns[1].count == 1, "expected timeout/disconnection count"); + const auto result = parser.parse_file(asset_path("parser_auth_families_syslog.log")); + + expect(result.events.size() == 4, "expected four recognized syslog auth-family events"); + expect(result.warnings.size() == 3, "expected three syslog auth-family warnings"); + expect(result.quality.total_lines == 7, "expected seven syslog auth-family lines"); + expect(result.quality.parsed_lines == 4, "expected four parsed syslog auth-family lines"); + expect(result.quality.unparsed_lines == 3, "expected three unparsed syslog auth-family lines"); + expect_close(result.quality.parse_success_rate, 4.0 / 7.0, 1e-9, + "expected syslog auth-family parse success rate"); + + expect(result.events[0].event_type == loglens::EventType::SshAcceptedPublicKey, + "expected accepted publickey auth-family event"); + expect(result.events[0].source_ip == "203.0.113.70", "expected accepted publickey source ip"); + expect(result.events[1].event_type == loglens::EventType::PamAuthFailure, + "expected pam_faillock preauth auth-family event"); + expect(result.events[1].username == "alice", "expected pam_faillock preauth username"); + expect(result.events[1].source_ip == "203.0.113.71", "expected pam_faillock preauth source ip"); + expect(result.events[2].event_type == loglens::EventType::PamAuthFailure, + "expected pam_faillock authfail auth-family event"); + expect(result.events[2].username == "bob", "expected pam_faillock authfail username"); + expect(result.events[2].source_ip == "203.0.113.72", "expected pam_faillock authfail source ip"); + expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, + "expected pam_sss failure auth-family event"); + expect(result.events[3].username == "dave", "expected pam_sss failure username"); + expect(result.events[3].source_ip.empty(), "expected pam_sss failure fixture to stay source-less"); + + expect(result.quality.top_unknown_patterns.size() == 3, "expected three syslog auth-family buckets"); + expect(result.quality.top_unknown_patterns[0].pattern == "pam_faillock_authsucc", + "expected pam_faillock authsucc telemetry bucket"); + expect(result.quality.top_unknown_patterns[0].count == 1, "expected one pam_faillock authsucc line"); + expect(result.quality.top_unknown_patterns[1].pattern == "pam_sss_authinfo_unavail", + "expected pam_sss authinfo-unavail telemetry bucket"); + expect(result.quality.top_unknown_patterns[1].count == 1, "expected one pam_sss authinfo-unavail line"); + expect(result.quality.top_unknown_patterns[2].pattern == "pam_sss_unknown_user", + "expected pam_sss unknown-user telemetry bucket"); + expect(result.quality.top_unknown_patterns[2].count == 1, "expected one pam_sss unknown-user line"); } -void test_stream_warnings_and_metadata() { - const auto parser = make_syslog_parser(); - std::istringstream input( - "Mar 10 08:20:10 example-host sshd[1240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2\n" - "bad-line\n"); - - const auto result = parser.parse_stream(input); - expect(result.events.size() == 1, "expected one parsed event"); - expect(result.warnings.size() == 1, "expected one warning"); - expect(result.warnings.front().line_number == 2, "expected warning line number"); - expect(result.metadata.input_mode == loglens::InputMode::SyslogLegacy, "expected syslog metadata mode"); - expect(result.metadata.assume_year == 2026, "expected syslog metadata year"); - expect(!result.metadata.timezone_present, "expected syslog metadata timezone flag"); - expect(result.quality.total_lines == 2, "expected total line count"); - expect(result.quality.parsed_lines == 1, "expected parsed line count"); - expect(result.quality.unparsed_lines == 1, "expected unparsed line count"); - expect(result.quality.parse_success_rate == 0.5, "expected parse success rate"); - expect(result.quality.top_unknown_patterns.size() == 1, "expected one unknown pattern"); - expect(result.quality.top_unknown_patterns.front().pattern == "missing_syslog_header_fields", - "expected normalized structural parse failure pattern"); +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() == 4, "expected four recognized journalctl auth-family events"); + expect(result.warnings.size() == 3, "expected three journalctl auth-family warnings"); + expect(result.quality.total_lines == 7, "expected seven journalctl auth-family lines"); + expect(result.quality.parsed_lines == 4, "expected four parsed journalctl auth-family lines"); + expect(result.quality.unparsed_lines == 3, "expected three unparsed journalctl auth-family lines"); + expect_close(result.quality.parse_success_rate, 4.0 / 7.0, 1e-9, + "expected journalctl auth-family parse success rate"); + + expect(result.events[0].event_type == loglens::EventType::SshAcceptedPublicKey, + "expected journalctl accepted publickey auth-family event"); + expect(result.events[0].source_ip == "203.0.113.70", "expected journalctl accepted publickey source ip"); + expect(result.events[1].event_type == loglens::EventType::PamAuthFailure, + "expected journalctl pam_faillock preauth auth-family event"); + expect(result.events[2].event_type == loglens::EventType::PamAuthFailure, + "expected journalctl pam_faillock authfail auth-family event"); + expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, + "expected journalctl pam_sss failure auth-family event"); + expect(result.events[3].source_ip.empty(), "expected journalctl pam_sss failure fixture to stay source-less"); + + expect(result.quality.top_unknown_patterns.size() == 3, "expected three journalctl auth-family buckets"); + expect(result.quality.top_unknown_patterns[0].pattern == "pam_faillock_authsucc", + "expected journalctl pam_faillock authsucc telemetry bucket"); + expect(result.quality.top_unknown_patterns[0].count == 1, "expected one journalctl pam_faillock authsucc line"); + expect(result.quality.top_unknown_patterns[1].pattern == "pam_sss_authinfo_unavail", + "expected journalctl pam_sss authinfo-unavail telemetry bucket"); + expect(result.quality.top_unknown_patterns[1].count == 1, "expected one journalctl pam_sss authinfo-unavail line"); + expect(result.quality.top_unknown_patterns[2].pattern == "pam_sss_unknown_user", + "expected journalctl pam_sss unknown-user telemetry bucket"); + expect(result.quality.top_unknown_patterns[2].count == 1, "expected one journalctl pam_sss unknown-user line"); } -void test_journalctl_metadata() { - const loglens::AuthLogParser parser(loglens::ParserConfig{ - loglens::InputMode::JournalctlShortFull, - std::nullopt}); - std::istringstream input( - "Tue 2026-03-10 08:20:10 UTC example-host sshd[2240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2\n" - "bad-line\n"); - - const auto result = parser.parse_stream(input); - expect(result.events.size() == 1, "expected one parsed journalctl event"); - expect(result.warnings.size() == 1, "expected one journalctl warning"); - expect(result.metadata.input_mode == loglens::InputMode::JournalctlShortFull, "expected journalctl metadata mode"); - expect(!result.metadata.assume_year.has_value(), "expected no assumed year for journalctl"); - expect(result.metadata.timezone_present, "expected journalctl timezone metadata"); - expect(result.quality.total_lines == 2, "expected journalctl total line count"); - expect(result.quality.parsed_lines == 1, "expected journalctl parsed line count"); - expect(result.quality.unparsed_lines == 1, "expected journalctl unparsed line count"); - expect(result.quality.parse_success_rate == 0.5, "expected journalctl parse success rate"); - expect(result.quality.top_unknown_patterns.size() == 1, "expected one journalctl unknown pattern"); - expect(result.quality.top_unknown_patterns.front().pattern == "missing_journalctl_short_full_header_fields", - "expected normalized journalctl failure pattern"); -} - -void test_syslog_fixture_matrix_file() { +void test_malformed_line() { const auto parser = make_syslog_parser(); - const auto result = parser.parse_file(asset_path("parser_fixture_matrix_syslog.log")); - - expect(result.events.size() == 6, "expected six recognized syslog fixture events"); - expect(result.warnings.size() == 6, "expected six syslog fixture warnings"); - expect(result.quality.total_lines == 12, "expected twelve syslog fixture lines"); - expect(result.quality.parsed_lines == 6, "expected six parsed syslog fixture lines"); - expect(result.quality.unparsed_lines == 6, "expected six unparsed syslog fixture lines"); - expect_close(result.quality.parse_success_rate, 0.5, 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"); - expect(result.events[2].event_type == loglens::EventType::SshInvalidUser, "expected invalid user variant"); - expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, "expected pam auth failure variant"); - expect(result.events[4].event_type == loglens::EventType::SessionOpened, "expected sudo session-opened variant"); - expect(result.events[5].event_type == loglens::EventType::SessionOpened, "expected su-l session-opened variant"); - expect(result.events[4].username == "alice", "expected sudo session actor username"); - expect(result.events[5].username == "bob", "expected su-l session actor username"); - - expect(result.quality.top_unknown_patterns.size() == 3, "expected three unknown syslog buckets"); - expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", - "expected preauth connection-close syslog bucket"); - expect(result.quality.top_unknown_patterns[0].count == 3, "expected three preauth connection-close syslog lines"); - expect(result.quality.top_unknown_patterns[1].pattern == "sshd_timeout_or_disconnection", - "expected timeout/disconnection syslog bucket"); - expect(result.quality.top_unknown_patterns[1].count == 2, "expected two timeout/disconnection syslog lines"); - expect(result.quality.top_unknown_patterns[2].pattern == "pam_unix_other", - "expected unsupported pam_unix syslog bucket"); - expect(result.quality.top_unknown_patterns[2].count == 1, "expected one unsupported pam_unix syslog line"); -} - -void test_journalctl_fixture_matrix_file() { - const loglens::AuthLogParser parser(loglens::ParserConfig{ - loglens::InputMode::JournalctlShortFull, - std::nullopt}); - const auto result = parser.parse_file(asset_path("parser_fixture_matrix_journalctl_short_full.log")); - - expect(result.events.size() == 6, "expected six recognized journalctl fixture events"); - expect(result.warnings.size() == 6, "expected six journalctl fixture warnings"); - expect(result.quality.total_lines == 12, "expected twelve journalctl fixture lines"); - expect(result.quality.parsed_lines == 6, "expected six parsed journalctl fixture lines"); - expect(result.quality.unparsed_lines == 6, "expected six unparsed journalctl fixture lines"); - expect_close(result.quality.parse_success_rate, 0.5, 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"); - expect(result.events[2].event_type == loglens::EventType::SshInvalidUser, "expected journalctl invalid user variant"); - expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, "expected journalctl pam auth failure variant"); - expect(result.events[4].event_type == loglens::EventType::SessionOpened, "expected journalctl sudo session-opened variant"); - expect(result.events[5].event_type == loglens::EventType::SessionOpened, "expected journalctl su-l session-opened variant"); - - expect(result.quality.top_unknown_patterns.size() == 3, "expected three unknown journalctl buckets"); - expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", - "expected preauth connection-close journalctl bucket"); - expect(result.quality.top_unknown_patterns[0].count == 3, "expected three preauth connection-close journalctl lines"); - expect(result.quality.top_unknown_patterns[1].pattern == "sshd_timeout_or_disconnection", - "expected timeout/disconnection journalctl bucket"); - expect(result.quality.top_unknown_patterns[1].count == 2, "expected two timeout/disconnection journalctl lines"); - expect(result.quality.top_unknown_patterns[2].pattern == "pam_unix_other", - "expected unsupported pam_unix journalctl bucket"); - expect(result.quality.top_unknown_patterns[2].count == 1, "expected one unsupported pam_unix journalctl line"); -} - -} // namespace - + std::string error; + const auto event = parser.parse_line("malformed log line without syslog header", 9, &error); + + expect(!event.has_value(), "expected malformed line to fail"); + expect(!error.empty(), "expected parse error for malformed line"); +} + +void test_unknown_auth_patterns_are_warnings_only() { + const auto parser = make_syslog_parser(); + std::istringstream input( + "Mar 10 08:11:22 example-host sshd[1234]: Failed password for root from 203.0.113.10 port 51022 ssh2\n" + "Mar 10 08:12:05 example-host sshd[1235]: Failed publickey for invalid user svc-backup from 203.0.113.10 port 51030 ssh2\n" + "Mar 10 08:13:10 example-host sshd[1236]: Connection closed by authenticating user alice 203.0.113.50 port 51290 [preauth]\n" + "Mar 10 08:14:44 example-host sshd[1237]: Timeout, client not responding from 203.0.113.51 port 51291\n"); + + const auto result = parser.parse_stream(input); + expect(result.events.size() == 2, "expected only recognized lines to become events"); + expect(result.warnings.size() == 2, "expected unknown auth patterns to become warnings"); + expect(result.quality.total_lines == 4, "expected total analyzed line count"); + expect(result.quality.parsed_lines == 2, "expected parsed line count"); + expect(result.quality.unparsed_lines == 2, "expected unparsed line count"); + expect(result.quality.parse_success_rate == 0.5, "expected parse success rate"); + expect(result.quality.top_unknown_patterns.size() == 2, "expected two unknown pattern buckets"); + expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", + "expected preauth connection close pattern"); + expect(result.quality.top_unknown_patterns[0].count == 1, "expected preauth connection close count"); + expect(result.quality.top_unknown_patterns[1].pattern == "sshd_timeout_or_disconnection", + "expected timeout/disconnection pattern"); + expect(result.quality.top_unknown_patterns[1].count == 1, "expected timeout/disconnection count"); +} + +void test_stream_warnings_and_metadata() { + const auto parser = make_syslog_parser(); + std::istringstream input( + "Mar 10 08:20:10 example-host sshd[1240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2\n" + "bad-line\n"); + + const auto result = parser.parse_stream(input); + expect(result.events.size() == 1, "expected one parsed event"); + expect(result.warnings.size() == 1, "expected one warning"); + expect(result.warnings.front().line_number == 2, "expected warning line number"); + expect(result.metadata.input_mode == loglens::InputMode::SyslogLegacy, "expected syslog metadata mode"); + expect(result.metadata.assume_year == 2026, "expected syslog metadata year"); + expect(!result.metadata.timezone_present, "expected syslog metadata timezone flag"); + expect(result.quality.total_lines == 2, "expected total line count"); + expect(result.quality.parsed_lines == 1, "expected parsed line count"); + expect(result.quality.unparsed_lines == 1, "expected unparsed line count"); + expect(result.quality.parse_success_rate == 0.5, "expected parse success rate"); + expect(result.quality.top_unknown_patterns.size() == 1, "expected one unknown pattern"); + expect(result.quality.top_unknown_patterns.front().pattern == "missing_syslog_header_fields", + "expected normalized structural parse failure pattern"); +} + +void test_journalctl_metadata() { + const loglens::AuthLogParser parser(loglens::ParserConfig{ + loglens::InputMode::JournalctlShortFull, + std::nullopt}); + std::istringstream input( + "Tue 2026-03-10 08:20:10 UTC example-host sshd[2240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2\n" + "bad-line\n"); + + const auto result = parser.parse_stream(input); + expect(result.events.size() == 1, "expected one parsed journalctl event"); + expect(result.warnings.size() == 1, "expected one journalctl warning"); + expect(result.metadata.input_mode == loglens::InputMode::JournalctlShortFull, "expected journalctl metadata mode"); + expect(!result.metadata.assume_year.has_value(), "expected no assumed year for journalctl"); + expect(result.metadata.timezone_present, "expected journalctl timezone metadata"); + expect(result.quality.total_lines == 2, "expected journalctl total line count"); + expect(result.quality.parsed_lines == 1, "expected journalctl parsed line count"); + expect(result.quality.unparsed_lines == 1, "expected journalctl unparsed line count"); + expect(result.quality.parse_success_rate == 0.5, "expected journalctl parse success rate"); + expect(result.quality.top_unknown_patterns.size() == 1, "expected one journalctl unknown pattern"); + expect(result.quality.top_unknown_patterns.front().pattern == "missing_journalctl_short_full_header_fields", + "expected normalized journalctl failure pattern"); +} + +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() == 6, "expected six recognized syslog fixture events"); + expect(result.warnings.size() == 6, "expected six syslog fixture warnings"); + expect(result.quality.total_lines == 12, "expected twelve syslog fixture lines"); + expect(result.quality.parsed_lines == 6, "expected six parsed syslog fixture lines"); + expect(result.quality.unparsed_lines == 6, "expected six unparsed syslog fixture lines"); + expect_close(result.quality.parse_success_rate, 0.5, 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"); + expect(result.events[2].event_type == loglens::EventType::SshInvalidUser, "expected invalid user variant"); + expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, "expected pam auth failure variant"); + expect(result.events[4].event_type == loglens::EventType::SessionOpened, "expected sudo session-opened variant"); + expect(result.events[5].event_type == loglens::EventType::SessionOpened, "expected su-l session-opened variant"); + expect(result.events[4].username == "alice", "expected sudo session actor username"); + expect(result.events[5].username == "bob", "expected su-l session actor username"); + + expect(result.quality.top_unknown_patterns.size() == 3, "expected three unknown syslog buckets"); + expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", + "expected preauth connection-close syslog bucket"); + expect(result.quality.top_unknown_patterns[0].count == 3, "expected three preauth connection-close syslog lines"); + expect(result.quality.top_unknown_patterns[1].pattern == "sshd_timeout_or_disconnection", + "expected timeout/disconnection syslog bucket"); + expect(result.quality.top_unknown_patterns[1].count == 2, "expected two timeout/disconnection syslog lines"); + expect(result.quality.top_unknown_patterns[2].pattern == "pam_unix_other", + "expected unsupported pam_unix syslog bucket"); + expect(result.quality.top_unknown_patterns[2].count == 1, "expected one unsupported pam_unix syslog line"); +} + +void test_journalctl_fixture_matrix_file() { + const loglens::AuthLogParser parser(loglens::ParserConfig{ + loglens::InputMode::JournalctlShortFull, + std::nullopt}); + const auto result = parser.parse_file(asset_path("parser_fixture_matrix_journalctl_short_full.log")); + + expect(result.events.size() == 6, "expected six recognized journalctl fixture events"); + expect(result.warnings.size() == 6, "expected six journalctl fixture warnings"); + expect(result.quality.total_lines == 12, "expected twelve journalctl fixture lines"); + expect(result.quality.parsed_lines == 6, "expected six parsed journalctl fixture lines"); + expect(result.quality.unparsed_lines == 6, "expected six unparsed journalctl fixture lines"); + expect_close(result.quality.parse_success_rate, 0.5, 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"); + expect(result.events[2].event_type == loglens::EventType::SshInvalidUser, "expected journalctl invalid user variant"); + expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, "expected journalctl pam auth failure variant"); + expect(result.events[4].event_type == loglens::EventType::SessionOpened, "expected journalctl sudo session-opened variant"); + expect(result.events[5].event_type == loglens::EventType::SessionOpened, "expected journalctl su-l session-opened variant"); + + expect(result.quality.top_unknown_patterns.size() == 3, "expected three unknown journalctl buckets"); + expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", + "expected preauth connection-close journalctl bucket"); + expect(result.quality.top_unknown_patterns[0].count == 3, "expected three preauth connection-close journalctl lines"); + expect(result.quality.top_unknown_patterns[1].pattern == "sshd_timeout_or_disconnection", + "expected timeout/disconnection journalctl bucket"); + expect(result.quality.top_unknown_patterns[1].count == 2, "expected two timeout/disconnection journalctl lines"); + expect(result.quality.top_unknown_patterns[2].pattern == "pam_unix_other", + "expected unsupported pam_unix journalctl bucket"); + expect(result.quality.top_unknown_patterns[2].count == 1, "expected one unsupported pam_unix journalctl line"); +} + +} // namespace + int main() { test_invalid_user_failure(); test_standard_failure(); test_success_event(); + test_accepted_publickey_success_event(); test_sudo_event(); test_failed_publickey_event(); test_pam_auth_failure_event(); + test_pam_sss_received_failure_event(); test_session_opened_event(); test_journalctl_short_full_event(); + test_syslog_auth_family_fixture_file(); + test_journalctl_auth_family_fixture_file(); test_malformed_line(); test_unknown_auth_patterns_are_warnings_only(); test_stream_warnings_and_metadata(); - test_journalctl_metadata(); - test_syslog_fixture_matrix_file(); - test_journalctl_fixture_matrix_file(); - return 0; -} + test_journalctl_metadata(); + test_syslog_fixture_matrix_file(); + test_journalctl_fixture_matrix_file(); + return 0; +}