From fb8c5c7ec57230538e4c74825f3c23ffc3923199 Mon Sep 17 00:00:00 2001 From: stacknil Date: Thu, 19 Mar 2026 16:43:01 +0800 Subject: [PATCH] feat: expand parser fixtures and unify sudo signals --- ...r_fixture_matrix_journalctl_short_full.log | 12 +++ assets/parser_fixture_matrix_syslog.log | 12 +++ src/detector.cpp | 30 ++---- src/signal.cpp | 84 +++++++++------ src/signal.hpp | 5 +- tests/test_detector.cpp | 82 ++++++++++++++ tests/test_parser.cpp | 100 ++++++++++++++++++ 7 files changed, 271 insertions(+), 54 deletions(-) create mode 100644 assets/parser_fixture_matrix_journalctl_short_full.log create mode 100644 assets/parser_fixture_matrix_syslog.log diff --git a/assets/parser_fixture_matrix_journalctl_short_full.log b/assets/parser_fixture_matrix_journalctl_short_full.log new file mode 100644 index 0000000..38486b4 --- /dev/null +++ b/assets/parser_fixture_matrix_journalctl_short_full.log @@ -0,0 +1,12 @@ +Tue 2026-03-10 09:00:01 UTC example-host sshd[3000]: Failed password for invalid user admin from 203.0.113.10 port 52000 ssh2 +Tue 2026-03-10 09:00:40 UTC example-host sshd[3001]: Failed publickey for alice from 203.0.113.11 port 52001 ssh2 +Tue 2026-03-10 09:01:15 UTC example-host sshd[3002]: Invalid user backup from 203.0.113.12 port 52002 +Tue 2026-03-10 09:01:52 UTC example-host pam_unix(sshd:auth): authentication failure; user=alice euid=0 tty=ssh rhost=203.0.113.40 +Tue 2026-03-10 09:02:30 UTC example-host pam_unix(sudo:session): session opened for user root(uid=0) by alice(uid=1000) +Tue 2026-03-10 09:03:05 UTC example-host pam_unix(su-l:session): session opened for user root by bob(uid=1001) +Tue 2026-03-10 09:03:40 UTC example-host sshd[3003]: Connection closed by user alice 203.0.113.50 port 52010 [preauth] +Tue 2026-03-10 09:04:05 UTC example-host sshd[3004]: Connection closed by authenticating user carol 203.0.113.51 port 52011 [preauth] +Tue 2026-03-10 09:04:28 UTC example-host sshd[3005]: Connection closed by invalid user deploy 203.0.113.52 port 52012 [preauth] +Tue 2026-03-10 09:05:02 UTC example-host sshd[3006]: Disconnected from authenticating user dave 203.0.113.53 port 52013 [preauth] +Tue 2026-03-10 09:05:34 UTC example-host sshd[3007]: Timeout, client not responding from 203.0.113.54 port 52014 +Tue 2026-03-10 09:06:10 UTC example-host pam_unix(sshd:session): session closed for user alice diff --git a/assets/parser_fixture_matrix_syslog.log b/assets/parser_fixture_matrix_syslog.log new file mode 100644 index 0000000..fface11 --- /dev/null +++ b/assets/parser_fixture_matrix_syslog.log @@ -0,0 +1,12 @@ +Mar 10 09:00:01 example-host sshd[2000]: Failed password for invalid user admin from 203.0.113.10 port 52000 ssh2 +Mar 10 09:00:40 example-host sshd[2001]: Failed publickey for alice from 203.0.113.11 port 52001 ssh2 +Mar 10 09:01:15 example-host sshd[2002]: Invalid user backup from 203.0.113.12 port 52002 +Mar 10 09:01:52 example-host pam_unix(sshd:auth): authentication failure; user=alice euid=0 tty=ssh rhost=203.0.113.40 +Mar 10 09:02:30 example-host pam_unix(sudo:session): session opened for user root(uid=0) by alice(uid=1000) +Mar 10 09:03:05 example-host pam_unix(su-l:session): session opened for user root by bob(uid=1001) +Mar 10 09:03:40 example-host sshd[2003]: Connection closed by user alice 203.0.113.50 port 52010 [preauth] +Mar 10 09:04:05 example-host sshd[2004]: Connection closed by authenticating user carol 203.0.113.51 port 52011 [preauth] +Mar 10 09:04:28 example-host sshd[2005]: Connection closed by invalid user deploy 203.0.113.52 port 52012 [preauth] +Mar 10 09:05:02 example-host sshd[2006]: Disconnected from authenticating user dave 203.0.113.53 port 52013 [preauth] +Mar 10 09:05:34 example-host sshd[2007]: Timeout, client not responding from 203.0.113.54 port 52014 +Mar 10 09:06:10 example-host pam_unix(sshd:session): session closed for user alice diff --git a/src/detector.cpp b/src/detector.cpp index 7aca0dc..6a1b24d 100644 --- a/src/detector.cpp +++ b/src/detector.cpp @@ -7,7 +7,6 @@ namespace loglens { namespace { using SignalGroup = std::unordered_map>; -using EventGroup = std::unordered_map>; std::vector sort_signals_by_time(const std::vector& signals) { auto sorted = signals; @@ -20,17 +19,6 @@ std::vector sort_signals_by_time(const std::vector sort_events_by_time(const std::vector& events) { - auto sorted = events; - std::sort(sorted.begin(), sorted.end(), [](const Event* left, const Event* right) { - if (left->timestamp != right->timestamp) { - return left->timestamp < right->timestamp; - } - return left->line_number < right->line_number; - }); - return sorted; -} - SignalGroup group_terminal_auth_failures_by_ip(const std::vector& signals) { SignalGroup grouped; for (const auto& signal : signals) { @@ -53,13 +41,13 @@ SignalGroup group_attempt_evidence_by_ip(const std::vector& signals) return grouped; } -EventGroup group_sudo_by_user(const std::vector& events) { - EventGroup grouped; - for (const auto& event : events) { - if (event.username.empty() || event.event_type != EventType::SudoCommand) { +SignalGroup group_sudo_burst_evidence_by_user(const std::vector& signals) { + SignalGroup grouped; + for (const auto& signal : signals) { + if (signal.username.empty() || !signal.counts_as_sudo_burst_evidence) { continue; } - grouped[event.username].push_back(&event); + grouped[signal.username].push_back(&signal); } return grouped; } @@ -220,12 +208,12 @@ std::vector detect_multi_user(const std::vector& signals, c return findings; } -std::vector detect_sudo_burst(const std::vector& events, const DetectorConfig& config) { +std::vector detect_sudo_burst(const std::vector& signals, const DetectorConfig& config) { std::vector findings; - const auto grouped = group_sudo_by_user(events); + const auto grouped = group_sudo_burst_evidence_by_user(signals); for (const auto& [username, group] : grouped) { - const auto ordered = sort_events_by_time(group); + const auto ordered = sort_signals_by_time(group); std::size_t start = 0; std::size_t best_count = 0; std::size_t best_start = 0; @@ -279,7 +267,7 @@ std::vector Detector::analyze(const std::vector& events) const { const auto auth_signals = build_auth_signals(events, config_.auth_signal_mappings); auto findings = detect_brute_force(auth_signals, config_); auto multi_user = detect_multi_user(auth_signals, config_); - auto sudo = detect_sudo_burst(events, config_); + auto sudo = detect_sudo_burst(auth_signals, config_); findings.insert(findings.end(), multi_user.begin(), multi_user.end()); findings.insert(findings.end(), sudo.begin(), sudo.end()); diff --git a/src/signal.cpp b/src/signal.cpp index 941a4c5..b07cffa 100644 --- a/src/signal.cpp +++ b/src/signal.cpp @@ -1,43 +1,62 @@ #include "signal.hpp" +#include + namespace loglens { namespace { -AuthSignalKind signal_kind_for_event_type(EventType type) { - switch (type) { - case EventType::SshFailedPassword: - return AuthSignalKind::SshFailedPassword; - case EventType::SshInvalidUser: - return AuthSignalKind::SshInvalidUser; - case EventType::SshFailedPublicKey: - return AuthSignalKind::SshFailedPublicKey; - case EventType::PamAuthFailure: - return AuthSignalKind::PamAuthFailure; - case EventType::Unknown: - case EventType::SshAcceptedPassword: - case EventType::SessionOpened: - case EventType::SudoCommand: - default: - return AuthSignalKind::Unknown; - } -} +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; +}; -const AuthSignalBehavior* behavior_for_event_type(EventType type, const AuthSignalConfig& config) { - switch (type) { +std::optional signal_mapping_for_event(const Event& event, const AuthSignalConfig& config) { + switch (event.event_type) { case EventType::SshFailedPassword: - return &config.ssh_failed_password; + 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 &config.ssh_invalid_user; + 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 &config.ssh_failed_publickey; + 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 &config.pam_auth_failure; + 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::SessionOpened: - case EventType::SudoCommand: default: - return nullptr; + return std::nullopt; } } @@ -48,8 +67,8 @@ std::vector build_auth_signals(const std::vector& events, con signals.reserve(events.size()); for (const auto& event : events) { - const auto* behavior = behavior_for_event_type(event.event_type, config); - if (behavior == nullptr) { + const auto mapping = signal_mapping_for_event(event, config); + if (!mapping.has_value()) { continue; } @@ -57,9 +76,10 @@ std::vector build_auth_signals(const std::vector& events, con event.timestamp, event.source_ip, event.username, - signal_kind_for_event_type(event.event_type), - behavior->counts_as_attempt_evidence, - behavior->counts_as_terminal_auth_failure, + mapping->signal_kind, + mapping->counts_as_attempt_evidence, + mapping->counts_as_terminal_auth_failure, + mapping->counts_as_sudo_burst_evidence, event.line_number}); } diff --git a/src/signal.hpp b/src/signal.hpp index 1ab5f1b..3d7d446 100644 --- a/src/signal.hpp +++ b/src/signal.hpp @@ -13,7 +13,9 @@ enum class AuthSignalKind { SshFailedPassword, SshInvalidUser, SshFailedPublicKey, - PamAuthFailure + PamAuthFailure, + SudoCommand, + SudoSessionOpened }; struct AuthSignalBehavior { @@ -35,6 +37,7 @@ struct AuthSignal { 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::size_t line_number = 0; }; diff --git a/tests/test_detector.cpp b/tests/test_detector.cpp index da0f60a..03c86c5 100644 --- a/tests/test_detector.cpp +++ b/tests/test_detector.cpp @@ -36,6 +36,13 @@ const loglens::AuthSignal* find_signal(const std::vector& s 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}); @@ -87,6 +94,23 @@ std::vector build_pam_bruteforce_candidate_events() { "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; @@ -149,6 +173,61 @@ void test_failed_publickey_contributes_to_bruteforce_by_default() { 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(); + 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; @@ -264,6 +343,9 @@ int main() { test_custom_thresholds(); test_auth_signal_defaults(); test_failed_publickey_contributes_to_bruteforce_by_default(); + 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(); diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index 5432db6..d4eca9e 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -1,8 +1,11 @@ #include "parser.hpp" +#include +#include #include #include #include +#include namespace { @@ -18,6 +21,37 @@ loglens::AuthLogParser make_syslog_parser() { 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; @@ -205,6 +239,70 @@ void test_journalctl_metadata() { "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() { @@ -220,5 +318,7 @@ int main() { 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; }