|
10 | 10 | #include <stdexcept> |
11 | 11 | #include <string> |
12 | 12 | #include <string_view> |
| 13 | +#include <vector> |
13 | 14 |
|
14 | 15 | namespace { |
15 | 16 |
|
@@ -49,6 +50,31 @@ std::vector<loglens::Event> parse_events(loglens::ParserConfig config, std::stri |
49 | 50 | return parser.parse_stream(input).events; |
50 | 51 | } |
51 | 52 |
|
| 53 | +std::filesystem::path repo_root() { |
| 54 | + const std::filesystem::path source_path{__FILE__}; |
| 55 | + std::vector<std::filesystem::path> candidates; |
| 56 | + |
| 57 | + if (source_path.is_absolute()) { |
| 58 | + candidates.push_back(source_path); |
| 59 | + } else { |
| 60 | + const auto cwd = std::filesystem::current_path(); |
| 61 | + candidates.push_back(cwd / source_path); |
| 62 | + candidates.push_back(cwd.parent_path() / source_path); |
| 63 | + } |
| 64 | + |
| 65 | + for (const auto& candidate : candidates) { |
| 66 | + if (std::filesystem::exists(candidate)) { |
| 67 | + return candidate.parent_path().parent_path(); |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + throw std::runtime_error("unable to resolve repository root from test source path"); |
| 72 | +} |
| 73 | + |
| 74 | +std::filesystem::path asset_path(std::string_view filename) { |
| 75 | + return repo_root() / "assets" / std::string(filename); |
| 76 | +} |
| 77 | + |
52 | 78 | loglens::ParserConfig make_syslog_config() { |
53 | 79 | return loglens::ParserConfig{ |
54 | 80 | loglens::InputMode::SyslogLegacy, |
@@ -121,6 +147,54 @@ std::vector<loglens::Event> build_sudo_burst_preservation_events() { |
121 | 147 | "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"); |
122 | 148 | } |
123 | 149 |
|
| 150 | +void expect_same_rule_threshold(const loglens::RuleThreshold& actual, |
| 151 | + const loglens::RuleThreshold& expected, |
| 152 | + const std::string& rule_name) { |
| 153 | + expect(actual.threshold == expected.threshold, "expected " + rule_name + " threshold to match default"); |
| 154 | + expect(actual.window == expected.window, "expected " + rule_name + " window to match default"); |
| 155 | +} |
| 156 | + |
| 157 | +void expect_same_auth_signal_behavior(const loglens::AuthSignalBehavior& actual, |
| 158 | + const loglens::AuthSignalBehavior& expected, |
| 159 | + const std::string& signal_name) { |
| 160 | + expect(actual.counts_as_attempt_evidence == expected.counts_as_attempt_evidence, |
| 161 | + "expected " + signal_name + " attempt-evidence mapping to match default"); |
| 162 | + expect(actual.counts_as_terminal_auth_failure == expected.counts_as_terminal_auth_failure, |
| 163 | + "expected " + signal_name + " terminal-failure mapping to match default"); |
| 164 | +} |
| 165 | + |
| 166 | +void expect_same_detector_config(const loglens::DetectorConfig& actual, |
| 167 | + const loglens::DetectorConfig& expected) { |
| 168 | + expect_same_rule_threshold(actual.brute_force, expected.brute_force, "brute_force"); |
| 169 | + expect_same_rule_threshold(actual.multi_user_probing, expected.multi_user_probing, "multi_user_probing"); |
| 170 | + expect_same_rule_threshold(actual.sudo_burst, expected.sudo_burst, "sudo_burst"); |
| 171 | + |
| 172 | + expect_same_auth_signal_behavior( |
| 173 | + actual.auth_signal_mappings.ssh_failed_password, |
| 174 | + expected.auth_signal_mappings.ssh_failed_password, |
| 175 | + "ssh_failed_password"); |
| 176 | + expect_same_auth_signal_behavior( |
| 177 | + actual.auth_signal_mappings.ssh_invalid_user, |
| 178 | + expected.auth_signal_mappings.ssh_invalid_user, |
| 179 | + "ssh_invalid_user"); |
| 180 | + expect_same_auth_signal_behavior( |
| 181 | + actual.auth_signal_mappings.ssh_failed_publickey, |
| 182 | + expected.auth_signal_mappings.ssh_failed_publickey, |
| 183 | + "ssh_failed_publickey"); |
| 184 | + expect_same_auth_signal_behavior( |
| 185 | + actual.auth_signal_mappings.ssh_failed_keyboard_interactive, |
| 186 | + expected.auth_signal_mappings.ssh_failed_keyboard_interactive, |
| 187 | + "ssh_failed_keyboard_interactive"); |
| 188 | + expect_same_auth_signal_behavior( |
| 189 | + actual.auth_signal_mappings.ssh_max_auth_tries, |
| 190 | + expected.auth_signal_mappings.ssh_max_auth_tries, |
| 191 | + "ssh_max_auth_tries"); |
| 192 | + expect_same_auth_signal_behavior( |
| 193 | + actual.auth_signal_mappings.pam_auth_failure, |
| 194 | + expected.auth_signal_mappings.pam_auth_failure, |
| 195 | + "pam_auth_failure"); |
| 196 | +} |
| 197 | + |
124 | 198 | void test_default_thresholds() { |
125 | 199 | const auto events = build_events(); |
126 | 200 | const loglens::Detector detector; |
@@ -341,6 +415,30 @@ void test_load_valid_config() { |
341 | 415 | expect(findings.size() == 3, "expected loaded config to preserve default findings"); |
342 | 416 | } |
343 | 417 |
|
| 418 | +void test_sample_config_matches_default_detector_contract() { |
| 419 | + const auto config = loglens::load_app_config(asset_path("sample_config.json")); |
| 420 | + expect(config.input_mode == loglens::InputMode::SyslogLegacy, |
| 421 | + "expected sample config to use syslog legacy input"); |
| 422 | + expect(config.timestamp.assume_year == 2026, |
| 423 | + "expected sample config to provide the sample syslog year"); |
| 424 | + expect_same_detector_config(config.detector, loglens::DetectorConfig{}); |
| 425 | + |
| 426 | + const auto events = build_events(); |
| 427 | + const loglens::Detector default_detector; |
| 428 | + const loglens::Detector sample_config_detector(config.detector); |
| 429 | + const auto default_findings = default_detector.analyze(events); |
| 430 | + const auto sample_config_findings = sample_config_detector.analyze(events); |
| 431 | + |
| 432 | + expect(sample_config_findings.size() == default_findings.size(), |
| 433 | + "expected sample config to preserve default finding count"); |
| 434 | + expect(find_finding(sample_config_findings, loglens::FindingType::BruteForce, "203.0.113.10") != nullptr, |
| 435 | + "expected sample config to preserve brute-force finding"); |
| 436 | + expect(find_finding(sample_config_findings, loglens::FindingType::MultiUserProbing, "203.0.113.10") != nullptr, |
| 437 | + "expected sample config to preserve multi-user finding"); |
| 438 | + expect(find_finding(sample_config_findings, loglens::FindingType::SudoBurst, "alice") != nullptr, |
| 439 | + "expected sample config to preserve sudo-burst finding"); |
| 440 | +} |
| 441 | + |
344 | 442 | void test_reject_invalid_config() { |
345 | 443 | const auto temp_path = std::filesystem::current_path() / "invalid_config_test.json"; |
346 | 444 | { |
@@ -389,6 +487,7 @@ int main() { |
389 | 487 | test_pam_auth_failure_does_not_trigger_bruteforce_by_default(); |
390 | 488 | test_equivalent_attack_scenario_yields_same_finding_count_across_modes(); |
391 | 489 | test_load_valid_config(); |
| 490 | + test_sample_config_matches_default_detector_contract(); |
392 | 491 | test_reject_invalid_config(); |
393 | 492 | return 0; |
394 | 493 | } |
0 commit comments