diff --git a/CHANGELOG.md b/CHANGELOG.md index c1abf0d..282bf05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ All notable user-visible changes should be recorded here. ### Added -- None yet. +- Added sanitized golden `report.md` / `report.json` regression fixtures to lock report contracts. +- Expanded parser coverage for `Accepted publickey` and selected `pam_faillock` / `pam_sss` variants. +- Added compact host-level summaries for multi-host reports. +- Added optional CSV export for findings and warnings when explicitly requested. ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f2f14a..939a2c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ target_include_directories(loglens_lib add_executable(loglens src/main.cpp) target_link_libraries(loglens PRIVATE loglens_lib) +target_compile_definitions(loglens PRIVATE LOGLENS_VERSION="${PROJECT_VERSION}") include(CTest) if(BUILD_TESTING) @@ -32,6 +33,10 @@ if(BUILD_TESTING) target_link_libraries(test_detector PRIVATE loglens_lib) add_test(NAME detector COMMAND test_detector) + add_executable(test_report tests/test_report.cpp) + target_link_libraries(test_report PRIVATE loglens_lib) + add_test(NAME report COMMAND test_report) + add_executable(test_cli tests/test_cli.cpp) target_link_libraries(test_cli PRIVATE loglens_lib) add_test( diff --git a/README.md b/README.md index 59ef294..958e07f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ It parses `auth.log` / `secure`-style syslog input and `journalctl --output=shor LogLens is an MVP / early release. The repository is stable enough for public review, local experimentation, and extension, but the parser and detection coverage are intentionally narrow. +Reviewing the project quickly? Start with [`docs/reviewer-path.md`](./docs/reviewer-path.md) and [`docs/reviewer-brief.md`](./docs/reviewer-brief.md). + ## Why This Project Exists Many small security tools can detect a handful of known log patterns. Fewer tools make their parsing limits visible. @@ -58,10 +60,14 @@ 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 beyond the core detector inputs, broadening coverage across common Linux auth families: +LogLens currently parses and reports these additional auth patterns beyond the core detector inputs: - `Accepted publickey` SSH successes +- `Accepted keyboard-interactive/pam` SSH successes - `Failed publickey` SSH failures, which count toward SSH brute-force detection by default +- `Failed keyboard-interactive/pam` and `maximum authentication attempts exceeded` SSH failures, which count toward SSH brute-force detection by default +- `sudo` command, password-failure, and sudoers policy-denial audit lines +- `su` success and failure audit lines - `pam_unix(...:auth): authentication failure` - `pam_unix(...:session): session opened` - selected `pam_faillock(...:auth)` failure variants @@ -69,12 +75,16 @@ LogLens currently parses and reports these additional auth patterns beyond the c LogLens also tracks parser coverage telemetry for unsupported or malformed lines, including: +- `total_input_lines` - `total_lines` +- `skipped_blank_lines` - `parsed_lines` - `unparsed_lines` - `parse_success_rate` - `top_unknown_patterns` +For the parser behavior contract, supported modes, and fixture map, see [`docs/parser-contract.md`](./docs/parser-contract.md). + LogLens does not currently detect: - Lateral movement @@ -96,9 +106,11 @@ For fresh-machine setup and repeatable local presets, see [`docs/dev-setup.md`]( ## Run ```bash +./build/loglens --help +./build/loglens --version ./build/loglens --mode syslog --year 2026 ./assets/sample_auth.log ./out -./build/loglens --mode journalctl-short-full ./assets/sample_journalctl_short_full.log ./out-journal -./build/loglens --config ./assets/sample_config.json ./assets/sample_auth.log ./out-config +./build/loglens --mode journalctl ./assets/sample_journalctl_short_full.log ./out-journal +./build/loglens --config=./assets/sample_config.json ./assets/sample_auth.log ./out-config ./build/loglens --mode syslog --year 2026 --csv ./assets/sample_auth.log ./out-csv ``` @@ -114,14 +126,16 @@ When you add `--csv`, LogLens also writes: - `findings.csv` - `warnings.csv` -Without `--csv`, LogLens does not create, overwrite, or delete any existing CSV files in the output directory. - The CSV schema is intentionally small and stable: - `findings.csv`: `rule`, `subject_kind`, `subject`, `event_count`, `window_start`, `window_end`, `usernames`, `summary` -- `warnings.csv`: `kind`, `message` +- `warnings.csv`: `kind`, `line_number`, `message` + +Without `--csv`, LogLens does not create, overwrite, or delete any existing CSV files in the output directory. -When an input spans multiple hostnames, both reports add compact host-level summaries without changing detector thresholds or introducing cross-host correlation logic. In `report.md` this appears as a host summary table, and in `report.json` it appears as a `host_summaries` array. +Formula-like CSV text fields are neutralized with a leading single quote so spreadsheet tools treat them as text. +When an input spans multiple hostnames, both reports add compact host-level summaries without changing detector thresholds or introducing cross-host correlation logic. +Markdown table fields escape table separators, line breaks, and HTML-sensitive characters so unusual log tokens cannot break report layout. ## Sample Output @@ -172,6 +186,14 @@ The config file schema is intentionally small and strict: "counts_as_attempt_evidence": true, "counts_as_terminal_auth_failure": true }, + "ssh_failed_keyboard_interactive": { + "counts_as_attempt_evidence": true, + "counts_as_terminal_auth_failure": true + }, + "ssh_max_auth_tries": { + "counts_as_attempt_evidence": true, + "counts_as_terminal_auth_failure": true + }, "pam_auth_failure": { "counts_as_attempt_evidence": true, "counts_as_terminal_auth_failure": false @@ -180,12 +202,13 @@ The config file schema is intentionally small and strict: } ``` -This mapping lets LogLens normalize parsed events into detection signals before applying brute-force or multi-user rules. By default, `pam_auth_failure` is treated as lower-confidence attempt evidence and does not count as a terminal authentication failure unless the config explicitly upgrades it. +This mapping lets LogLens normalize parsed events into detection signals before applying brute-force or multi-user rules. By default, `pam_auth_failure` is treated as lower-confidence attempt evidence and does not count as a terminal authentication failure unless the config explicitly upgrades it. The `ssh_failed_keyboard_interactive` and `ssh_max_auth_tries` mapping keys are optional in older configs and default to terminal failure evidence. Timestamp handling is now explicit: -- `--mode syslog` or `input_mode: syslog_legacy` requires `--year` or `timestamp.assume_year` -- `--mode journalctl-short-full` or `input_mode: journalctl_short_full` parses the embedded year and timezone and ignores `assume_year` +- `--mode syslog`, `--mode syslog-legacy`, or `input_mode: syslog_legacy` requires `--year` or `timestamp.assume_year` +- `--year` and `timestamp.assume_year` must use a four-digit year, for example `2026` +- `--mode journalctl`, `--mode journalctl-short-full`, or `input_mode: journalctl_short_full` parses the embedded year and timezone and ignores `assume_year` ## Example Input @@ -213,7 +236,7 @@ Tue 2026-03-10 08:31:18 UTC example-host sshd[2245]: Connection closed by authen - `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 still selective: it covers common `sshd`, `sudo`, `pam_unix`, and selected `pam_faillock` / `pam_sss` variants rather than broad Linux auth-family support. +- Parser coverage is still selective: it covers common `sshd`, `sudo`, `su`, `pam_unix`, and selected `pam_faillock` / `pam_sss` variants rather than broad Linux auth-family support. - 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. diff --git a/assets/parser_fixture_matrix_journalctl_short_full.log b/assets/parser_fixture_matrix_journalctl_short_full.log index b30acf3..a4e217a 100644 --- a/assets/parser_fixture_matrix_journalctl_short_full.log +++ b/assets/parser_fixture_matrix_journalctl_short_full.log @@ -6,6 +6,13 @@ Tue 2026-03-10 09:02:30 UTC example-host pam_unix(sudo:session): session opened 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:28 UTC example-host sshd[3008]: Accepted password for alice from 203.0.113.41 port 52003 ssh2 Tue 2026-03-10 09:03:34 UTC example-host sshd[3009]: Accepted publickey for carol from 203.0.113.42 port 52004 ssh2: ED25519 SHA256:SANITIZEDKEY2 +Tue 2026-03-10 09:03:35 UTC example-host sshd[3012]: Accepted keyboard-interactive/pam for dave from 203.0.113.43 port 52005 ssh2 +Tue 2026-03-10 09:03:36 UTC example-host sudo[3013]: alice : 1 incorrect password attempt ; TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl status ssh +Tue 2026-03-10 09:03:37 UTC example-host sudo[3014]: bob : user NOT in sudoers ; TTY=pts/1 ; PWD=/home/bob ; USER=root ; COMMAND=/usr/bin/id +Tue 2026-03-10 09:03:38 UTC example-host su[3015]: FAILED SU (to root) carol on pts/1 +Tue 2026-03-10 09:03:39 UTC example-host su[3016]: Successful su for root by dave +Tue 2026-03-10 09:03:39 UTC example-host sshd[3017]: Failed keyboard-interactive/pam for eve from 203.0.113.44 port 52006 ssh2 +Tue 2026-03-10 09:03:39 UTC example-host sshd[3018]: maximum authentication attempts exceeded for frank from 203.0.113.45 port 52007 ssh2 [preauth] Tue 2026-03-10 09:03: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] diff --git a/assets/parser_fixture_matrix_syslog.log b/assets/parser_fixture_matrix_syslog.log index 1c1e877..06535a5 100644 --- a/assets/parser_fixture_matrix_syslog.log +++ b/assets/parser_fixture_matrix_syslog.log @@ -6,6 +6,13 @@ Mar 10 09:02:30 example-host pam_unix(sudo:session): session opened for user roo 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:28 example-host sshd[2008]: Accepted password for alice from 203.0.113.41 port 52003 ssh2 Mar 10 09:03:34 example-host sshd[2009]: Accepted publickey for carol from 203.0.113.42 port 52004 ssh2: ED25519 SHA256:SANITIZEDKEY2 +Mar 10 09:03:35 example-host sshd[2012]: Accepted keyboard-interactive/pam for dave from 203.0.113.43 port 52005 ssh2 +Mar 10 09:03:36 example-host sudo[2013]: alice : 1 incorrect password attempt ; TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl status ssh +Mar 10 09:03:37 example-host sudo[2014]: bob : user NOT in sudoers ; TTY=pts/1 ; PWD=/home/bob ; USER=root ; COMMAND=/usr/bin/id +Mar 10 09:03:38 example-host su[2015]: FAILED SU (to root) carol on pts/1 +Mar 10 09:03:39 example-host su[2016]: Successful su for root by dave +Mar 10 09:03:39 example-host sshd[2017]: Failed keyboard-interactive/pam for eve from 203.0.113.44 port 52006 ssh2 +Mar 10 09:03:39 example-host sshd[2018]: maximum authentication attempts exceeded for frank from 203.0.113.45 port 52007 ssh2 [preauth] Mar 10 09:03: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] diff --git a/assets/sample_config.json b/assets/sample_config.json index b6200eb..86c2145 100644 --- a/assets/sample_config.json +++ b/assets/sample_config.json @@ -19,6 +19,14 @@ "counts_as_attempt_evidence": true, "counts_as_terminal_auth_failure": true }, + "ssh_failed_keyboard_interactive": { + "counts_as_attempt_evidence": true, + "counts_as_terminal_auth_failure": true + }, + "ssh_max_auth_tries": { + "counts_as_attempt_evidence": true, + "counts_as_terminal_auth_failure": true + }, "pam_auth_failure": { "counts_as_attempt_evidence": true, "counts_as_terminal_auth_failure": false diff --git a/docs/parser-contract.md b/docs/parser-contract.md new file mode 100644 index 0000000..0e0c91c --- /dev/null +++ b/docs/parser-contract.md @@ -0,0 +1,88 @@ +# Parser contract + +LogLens treats parser behavior as reviewable output, not as a hidden implementation detail. A line is either recognized as a typed event, skipped as blank input, or surfaced as a warning with coverage telemetry. + +The guiding rule is: + +> Parser observability > silent detection claims. + +## Supported input modes + +| Mode | Typical source | Timestamp behavior | Review anchor | +| --- | --- | --- | --- | +| `syslog_legacy` | `auth.log` / `secure` style lines such as `Mar 10 08:11:22 example-host sshd[1234]: ...` | Requires an explicit four-digit year from `--year` or `timestamp.assume_year` | [`assets/parser_fixture_matrix_syslog.log`](../assets/parser_fixture_matrix_syslog.log) | +| `journalctl_short_full` | `journalctl --output=short-full` style lines such as `Tue 2026-03-10 08:11:22 UTC example-host sshd[1234]: ...` | Uses the embedded year and supported timezone token | [`assets/parser_fixture_matrix_journalctl_short_full.log`](../assets/parser_fixture_matrix_journalctl_short_full.log) | + +Supported timezone tokens for `journalctl_short_full` are intentionally narrow: `UTC`, `GMT`, `Z`, and numeric offsets such as `+0000` or `+00:00`. + +## Recognized event families + +The parser currently recognizes common authentication evidence from: + +- `sshd` +- `sudo` +- `su` +- `pam_unix(...)` +- selected `pam_faillock(...)` variants +- selected `pam_sss(...)` variants + +Recognized SSH failure families include failed password, invalid user, failed publickey, failed keyboard-interactive/pam, and maximum-authentication-attempts-exceeded lines. These are normalized into event types and can become detection signals. + +Recognized success or audit families include accepted password, accepted publickey, accepted keyboard-interactive/pam, sudo command audit lines, sudo password failures, sudoers policy denials, su success/failure audit lines, and selected PAM session/auth lines. + +## Line handling contract + +| Input line outcome | Parser behavior | Report behavior | +| --- | --- | --- | +| Recognized auth line | Emits a typed `Event` with timestamp, hostname, program, optional pid, message, source IP, username, event type, and line number | Can contribute to summaries, reports, and configured detection signals | +| Blank line | Skips the line and increments `skipped_blank_lines` | Does not become a warning or parsed event | +| Malformed header | Emits a parser warning with the original line number and structural reason | Counts toward `unparsed_lines` and `top_unknown_patterns` | +| Well-formed but unsupported auth pattern | Emits a parser warning with an unknown-pattern bucket | Stays visible as telemetry instead of being silently ignored | + +This is the main trust boundary: unsupported input should remain inspectable, even when it does not produce a finding. + +## Detection signal boundary + +Parsing a line does not automatically mean it should drive a detector. LogLens keeps that boundary explicit through `AuthSignalConfig`. + +Default terminal SSH failure evidence: + +- `ssh_failed_password` +- `ssh_invalid_user` +- `ssh_failed_publickey` +- `ssh_failed_keyboard_interactive` +- `ssh_max_auth_tries` + +Default lower-confidence attempt evidence: + +- `pam_auth_failure`, which is attempt evidence but not terminal failure evidence unless configured otherwise + +Default sudo burst evidence: + +- `sudo_command` + +Parsed successes and audit-only events remain reportable but do not count as brute-force or multi-user failure evidence by default. + +## Test corpus map + +| Artifact | What it proves | +| --- | --- | +| [`tests/test_parser.cpp`](../tests/test_parser.cpp) | Unit-level parser expectations, malformed-line behavior, mode aliases, fixture-matrix counts, and unknown-pattern buckets | +| [`tests/test_detector.cpp`](../tests/test_detector.cpp) | Detection signal mapping and default counting behavior after parsing | +| [`assets/parser_fixture_matrix_syslog.log`](../assets/parser_fixture_matrix_syslog.log) | Syslog known/unknown parser matrix | +| [`assets/parser_fixture_matrix_journalctl_short_full.log`](../assets/parser_fixture_matrix_journalctl_short_full.log) | Journalctl short-full known/unknown parser matrix | +| [`assets/parser_auth_families_syslog.log`](../assets/parser_auth_families_syslog.log) | Syslog PAM/auth-family parser coverage | +| [`assets/parser_auth_families_journalctl_short_full.log`](../assets/parser_auth_families_journalctl_short_full.log) | Journalctl PAM/auth-family parser coverage | +| [`tests/test_report_contracts.cpp`](../tests/test_report_contracts.cpp) | Stable report-shape expectations for generated artifacts | + +## Non-goals + +The parser does not try to: + +- infer missing syslog years +- support every Linux authentication log variant +- classify unsupported lines as findings +- correlate across files or hosts +- produce incident verdicts + +Those boundaries are intentional for the MVP. The current priority is to keep parser coverage explicit and safely extensible. diff --git a/docs/reviewer-path.md b/docs/reviewer-path.md new file mode 100644 index 0000000..cd04702 --- /dev/null +++ b/docs/reviewer-path.md @@ -0,0 +1,85 @@ +# Reviewer Path + +This path is for reviewers who want to understand LogLens quickly without reading the whole repository first. + +## 30-second orientation + +Read: + +- [`README.md`](../README.md) +- [`docs/reviewer-brief.md`](./reviewer-brief.md) + +Confirm: + +- LogLens is an offline C++20 CLI for Linux authentication log analysis. +- It parses `auth.log` / `secure` style syslog input and `journalctl --output=short-full` style input. +- It emits deterministic Markdown, JSON, and optional CSV reports. +- Parser coverage telemetry is part of the output, not an internal-only detail. + +Core review lens: + +> Parser observability > silent detection claims. + +## 5-minute artifact review + +Inspect: + +- [`assets/sample_auth.log`](../assets/sample_auth.log) +- [`assets/sample_journalctl_short_full.log`](../assets/sample_journalctl_short_full.log) +- [`tests/fixtures/report_contracts/syslog_legacy/report.md`](../tests/fixtures/report_contracts/syslog_legacy/report.md) +- [`tests/fixtures/report_contracts/syslog_legacy/report.json`](../tests/fixtures/report_contracts/syslog_legacy/report.json) +- [`docs/parser-contract.md`](./parser-contract.md) + +Look for parser coverage fields: + +- `total_input_lines` +- `total_lines` +- `skipped_blank_lines` +- `parsed_lines` +- `unparsed_lines` +- `parse_success_rate` +- `top_unknown_patterns` + +Good stopping point: the reviewer can explain what LogLens parses, what it reports, and how unsupported lines remain visible. + +## 15-minute local check + +Run: + +```bash +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +./build/loglens --mode syslog --year 2026 ./assets/sample_auth.log ./out +``` + +Then inspect: + +- `out/report.md` +- `out/report.json` + +Optional CSV check: + +```bash +./build/loglens --mode syslog --year 2026 --csv ./assets/sample_auth.log ./out-csv +``` + +Then inspect: + +- `out-csv/findings.csv` +- `out-csv/warnings.csv` + +Good stopping point: the reviewer can build, test, run a sample, and compare generated artifacts with the report-contract fixtures. + +## Boundaries + +LogLens is intentionally narrow: + +- no live collection +- no credential attack automation +- no exploitation, persistence, or offensive workflow support +- no SIEM replacement +- no cross-host correlation engine +- no incident verdict or attribution claim + +Findings are rule-based triage aids. The parser boundary is the main trust boundary: recognized lines become typed events, unsupported lines become warnings and telemetry, and malformed input should fail gracefully. diff --git a/src/config.cpp b/src/config.cpp index 2dce28f..1c00696 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -260,6 +260,10 @@ AuthSignalConfig parse_auth_signal_config(JsonCursor& cursor) { } else if (key == "ssh_failed_publickey") { config.ssh_failed_publickey = parse_auth_signal_behavior(cursor, key); ssh_failed_publickey_seen = true; + } else if (key == "ssh_failed_keyboard_interactive") { + config.ssh_failed_keyboard_interactive = parse_auth_signal_behavior(cursor, key); + } else if (key == "ssh_max_auth_tries") { + config.ssh_max_auth_tries = parse_auth_signal_behavior(cursor, key); } else if (key == "pam_auth_failure") { config.pam_auth_failure = parse_auth_signal_behavior(cursor, key); pam_auth_failure_seen = true; @@ -300,6 +304,9 @@ TimestampConfig parse_timestamp_config(JsonCursor& cursor) { cursor.expect(':', key); if (key == "assume_year") { config.assume_year = cursor.parse_positive_int(key); + if (*config.assume_year < 1000 || *config.assume_year > 9999) { + throw std::runtime_error("invalid config.json: timestamp.assume_year must be a four-digit year"); + } assume_year_seen = true; } else { throw std::runtime_error("invalid config.json: unexpected key '" + key + "' in timestamp"); diff --git a/src/event.hpp b/src/event.hpp index 2bd7e92..2cf5aea 100644 --- a/src/event.hpp +++ b/src/event.hpp @@ -13,11 +13,17 @@ enum class EventType { SshFailedPassword, SshAcceptedPassword, SshAcceptedPublicKey, + SshAcceptedKeyboardInteractive, SshInvalidUser, SshFailedPublicKey, + SshFailedKeyboardInteractive, + SshMaxAuthTries, PamAuthFailure, SessionOpened, - SudoCommand + SudoCommand, + SudoAuthFailure, + SudoPolicyDenied, + SuAuthFailure }; struct Event { @@ -40,16 +46,28 @@ inline std::string to_string(EventType type) { return "ssh_accepted_password"; case EventType::SshAcceptedPublicKey: return "ssh_accepted_publickey"; + case EventType::SshAcceptedKeyboardInteractive: + return "ssh_accepted_keyboard_interactive"; case EventType::SshInvalidUser: return "ssh_invalid_user"; case EventType::SshFailedPublicKey: return "ssh_failed_publickey"; + case EventType::SshFailedKeyboardInteractive: + return "ssh_failed_keyboard_interactive"; + case EventType::SshMaxAuthTries: + return "ssh_max_auth_tries"; case EventType::PamAuthFailure: return "pam_auth_failure"; case EventType::SessionOpened: return "session_opened"; case EventType::SudoCommand: return "sudo_command"; + case EventType::SudoAuthFailure: + return "sudo_auth_failure"; + case EventType::SudoPolicyDenied: + return "sudo_policy_denied"; + case EventType::SuAuthFailure: + return "su_auth_failure"; case EventType::Unknown: default: return "unknown"; diff --git a/src/main.cpp b/src/main.cpp index fa84af0..fb6dfc5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,15 +4,22 @@ #include "report.hpp" #include +#include #include #include #include #include #include +#ifndef LOGLENS_VERSION +#define LOGLENS_VERSION "unknown" +#endif + namespace { struct CliOptions { + bool show_help = false; + bool show_version = false; std::optional config_path; std::optional input_mode; std::optional assumed_year; @@ -21,16 +28,46 @@ struct CliOptions { std::filesystem::path output_directory; }; -void print_usage() { - std::cerr << "Usage: loglens [--config ] [--mode ] [--year ] [--csv] [output_dir]\n"; +void print_usage(std::ostream& output) { + output << "Usage:\n" + << " loglens --help\n" + << " loglens --version\n" + << " loglens [--config ] [--mode ] [--year ] [--csv] [output_dir]\n"; + output << "Options with values also support --option=value syntax.\n"; +} + +void print_version(std::ostream& output) { + output << "LogLens " << LOGLENS_VERSION << '\n'; +} + +std::optional option_value(std::string_view argument, std::string_view option_name) { + if (!argument.starts_with(option_name) || argument.size() <= option_name.size()) { + return std::nullopt; + } + + if (argument[option_name.size()] != '=') { + return std::nullopt; + } + + return argument.substr(option_name.size() + 1); } int parse_year_argument(std::string_view value) { + if (value.size() != 4) { + throw std::runtime_error("invalid year value: " + std::string(value)); + } + + for (const char character : value) { + if (std::isdigit(static_cast(character)) == 0) { + throw std::runtime_error("invalid year value: " + std::string(value)); + } + } + int parsed_year = 0; const auto* begin = value.data(); const auto* end = value.data() + value.size(); const auto result = std::from_chars(begin, end, parsed_year); - if (result.ec != std::errc{} || result.ptr != end || parsed_year <= 0) { + if (result.ec != std::errc{} || result.ptr != end || parsed_year < 1000 || parsed_year > 9999) { throw std::runtime_error("invalid year value: " + std::string(value)); } @@ -47,6 +84,29 @@ CliOptions parse_cli_options(int argc, char* argv[]) { while (index < argc) { const std::string_view argument = argv[index]; + if (argument == "--") { + ++index; + break; + } + + if (argument == "--help" || argument == "-h") { + if (argc != 2) { + throw std::runtime_error("--help cannot be combined with other arguments"); + } + + options.show_help = true; + return options; + } + + if (argument == "--version") { + if (argc != 2) { + throw std::runtime_error("--version cannot be combined with other arguments"); + } + + options.show_version = true; + return options; + } + if (argument == "--config") { if (index + 1 >= argc) { throw std::runtime_error("missing path after --config"); @@ -57,6 +117,16 @@ CliOptions parse_cli_options(int argc, char* argv[]) { continue; } + if (const auto value = option_value(argument, "--config"); value.has_value()) { + if (value->empty()) { + throw std::runtime_error("missing path after --config"); + } + + options.config_path = std::filesystem::path{std::string{*value}}; + ++index; + continue; + } + if (argument == "--mode") { if (index + 1 >= argc) { throw std::runtime_error("missing value after --mode"); @@ -72,6 +142,21 @@ CliOptions parse_cli_options(int argc, char* argv[]) { continue; } + if (const auto value = option_value(argument, "--mode"); value.has_value()) { + if (value->empty()) { + throw std::runtime_error("missing value after --mode"); + } + + const auto parsed_mode = loglens::parse_input_mode(*value); + if (!parsed_mode.has_value()) { + throw std::runtime_error("unsupported mode: " + std::string{*value}); + } + + options.input_mode = *parsed_mode; + ++index; + continue; + } + if (argument == "--year") { if (index + 1 >= argc) { throw std::runtime_error("missing value after --year"); @@ -82,6 +167,16 @@ CliOptions parse_cli_options(int argc, char* argv[]) { continue; } + if (const auto value = option_value(argument, "--year"); value.has_value()) { + if (value->empty()) { + throw std::runtime_error("missing value after --year"); + } + + options.assumed_year = parse_year_argument(*value); + ++index; + continue; + } + if (argument == "--csv") { options.emit_csv = true; ++index; @@ -137,11 +232,21 @@ int main(int argc, char* argv[]) { try { options = parse_cli_options(argc, argv); } catch (const std::exception& error) { - print_usage(); + print_usage(std::cerr); std::cerr << "LogLens failed: " << error.what() << '\n'; return 1; } + if (options.show_help) { + print_usage(std::cout); + return 0; + } + + if (options.show_version) { + print_version(std::cout); + return 0; + } + try { const auto app_config = options.config_path.has_value() ? loglens::load_app_config(*options.config_path) diff --git a/src/parser.cpp b/src/parser.cpp index 04b0b26..2a974cd 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -93,7 +93,7 @@ bool parse_clock_token(std::string_view token, ClockTime& time) { && time.second >= 0 && time.second <= 59; } - if (token[8] != '.') { + if (token[8] != '.' || token.size() == 9) { return false; } @@ -181,7 +181,7 @@ bool parse_timezone_token(std::string_view token, std::chrono::minutes& offset) if (negative) { offset = -offset; } - return true; + return false; } void parse_program_tag(std::string_view tag, std::string& program, std::optional& pid) { @@ -345,6 +345,24 @@ bool parse_ssh_accepted_publickey_message(std::string_view message, Event& event return true; } +bool parse_ssh_accepted_keyboard_interactive_message(std::string_view message, Event& event) { + static constexpr std::string_view accepted_prefix = "Accepted keyboard-interactive/pam 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::SshAcceptedKeyboardInteractive; + 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,54 @@ bool parse_ssh_failed_publickey_message(std::string_view message, Event& event) return true; } +bool parse_ssh_failed_keyboard_interactive_message(std::string_view message, Event& event) { + static constexpr std::string_view keyboard_prefix = "Failed keyboard-interactive/pam for "; + if (!message.starts_with(keyboard_prefix)) { + return false; + } + + auto remaining = message.substr(keyboard_prefix.size()); + bool invalid_user = false; + if (remaining.starts_with("invalid user ")) { + invalid_user = true; + remaining.remove_prefix(std::string_view{"invalid user "}.size()); + } + + const 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 = invalid_user ? EventType::SshInvalidUser : EventType::SshFailedKeyboardInteractive; + return true; +} + +bool parse_ssh_max_auth_tries_message(std::string_view message, Event& event) { + static constexpr std::string_view max_auth_prefix = "maximum authentication attempts exceeded for "; + if (!message.starts_with(max_auth_prefix)) { + return false; + } + + auto remaining = message.substr(max_auth_prefix.size()); + bool invalid_user = false; + if (remaining.starts_with("invalid user ")) { + invalid_user = true; + remaining.remove_prefix(std::string_view{"invalid user "}.size()); + } + + const 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 = invalid_user ? EventType::SshInvalidUser : EventType::SshMaxAuthTries; + return true; +} + bool parse_ssh_invalid_user_message(std::string_view message, Event& event) { static constexpr std::string_view invalid_user_prefix = "Invalid user "; if (!message.starts_with(invalid_user_prefix)) { @@ -480,10 +546,77 @@ bool parse_sudo_message(std::string_view message, Event& event) { } event.username.assign(username); + const auto details = trim_left(remaining.substr(separator + 1)); + if (details.find("incorrect password attempt") != std::string_view::npos) { + event.event_type = EventType::SudoAuthFailure; + return true; + } + + if (details.find("user NOT in sudoers") != std::string_view::npos + || details.find("command not allowed") != std::string_view::npos) { + event.event_type = EventType::SudoPolicyDenied; + return true; + } + + if (details.find("COMMAND=") == std::string_view::npos) { + return false; + } + event.event_type = EventType::SudoCommand; return true; } +bool parse_su_message(std::string_view message, Event& event) { + static constexpr std::string_view failed_prefix = "FAILED SU (to "; + static constexpr std::string_view success_prefix = "Successful su for "; + + if (message.starts_with(failed_prefix)) { + const auto close_target = message.find(") "); + if (close_target == std::string_view::npos) { + return false; + } + + auto remaining = message.substr(close_target + 2); + const auto location_marker = remaining.find(" on "); + if (location_marker != std::string_view::npos) { + remaining = remaining.substr(0, location_marker); + } + + const auto actor = trim(remaining); + if (actor.empty()) { + return false; + } + + event.username.assign(actor); + event.event_type = EventType::SuAuthFailure; + return true; + } + + if (message.starts_with(success_prefix)) { + const auto by_position = message.find(" by "); + if (by_position == std::string_view::npos) { + return false; + } + + auto actor = message.substr(by_position + std::string_view{" by "}.size()); + const auto actor_end = actor.find_first_of("( "); + if (actor_end != std::string_view::npos) { + actor = actor.substr(0, actor_end); + } + + actor = trim(actor); + if (actor.empty()) { + return false; + } + + event.username.assign(actor); + event.event_type = EventType::SessionOpened; + return true; + } + + return false; +} + 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; @@ -549,6 +682,10 @@ std::string classify_unknown_auth_pattern(const Event& event) { return "sudo_other"; } + if (event.program == "su") { + return "su_other"; + } + return "program_" + sanitize_pattern_label(event.program); } @@ -564,9 +701,18 @@ bool classify_event(Event& event) { if (parse_ssh_accepted_publickey_message(message, event)) { return true; } + if (parse_ssh_accepted_keyboard_interactive_message(message, event)) { + return true; + } if (parse_ssh_failed_publickey_message(message, event)) { return true; } + if (parse_ssh_failed_keyboard_interactive_message(message, event)) { + return true; + } + if (parse_ssh_max_auth_tries_message(message, event)) { + return true; + } if (parse_ssh_invalid_user_message(message, event)) { return true; } @@ -601,6 +747,10 @@ bool classify_event(Event& event) { return parse_sudo_message(message, event); } + if (event.program == "su") { + return parse_su_message(message, event); + } + return false; } @@ -774,11 +924,13 @@ std::string to_string(InputMode mode) { } std::optional parse_input_mode(std::string_view value) { - if (value == "syslog" || value == "syslog_legacy") { + if (value == "syslog" || value == "syslog-legacy" || value == "syslog_legacy") { return InputMode::SyslogLegacy; } - if (value == "journalctl-short-full" || value == "journalctl_short_full") { + if (value == "journalctl" + || value == "journalctl-short-full" + || value == "journalctl_short_full") { return InputMode::JournalctlShortFull; } @@ -823,6 +975,7 @@ ParseReport AuthLogParser::parse_stream(std::istream& input) const { while (std::getline(input, line)) { ++line_number; if (trim(line).empty()) { + ++result.quality.skipped_blank_lines; continue; } diff --git a/src/parser.hpp b/src/parser.hpp index a7f2d23..0302040 100644 --- a/src/parser.hpp +++ b/src/parser.hpp @@ -42,6 +42,7 @@ struct UnknownPatternCount { struct ParserQualityMetrics { std::size_t total_lines = 0; + std::size_t skipped_blank_lines = 0; std::size_t parsed_lines = 0; std::size_t unparsed_lines = 0; double parse_success_rate = 0.0; diff --git a/src/report.cpp b/src/report.cpp index e68797a..afaaef0 100644 --- a/src/report.cpp +++ b/src/report.cpp @@ -6,8 +6,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -23,6 +25,17 @@ struct HostSummary { std::vector> event_counts; }; +void append_unicode_escape(std::string& output, unsigned char value) { + std::ostringstream escaped_code; + escaped_code << "\\u" + << std::uppercase + << std::hex + << std::setfill('0') + << std::setw(4) + << static_cast(value); + output += escaped_code.str(); +} + std::string escape_json(std::string_view value) { std::string escaped; escaped.reserve(value.size()); @@ -45,7 +58,11 @@ std::string escape_json(std::string_view value) { escaped += "\\t"; break; default: - escaped += character; + if (static_cast(character) < 0x20) { + append_unicode_escape(escaped, static_cast(character)); + } else { + escaped += character; + } break; } } @@ -53,16 +70,97 @@ std::string escape_json(std::string_view value) { return escaped; } +std::string escape_markdown_table_cell(std::string_view value) { + std::string escaped; + escaped.reserve(value.size()); + + for (std::size_t index = 0; index < value.size(); ++index) { + const char character = value[index]; + switch (character) { + case '\\': + escaped += "\\\\"; + break; + case '|': + escaped += "\\|"; + break; + case '\r': + if (index + 1 < value.size() && value[index + 1] == '\n') { + ++index; + } + escaped += "
"; + break; + case '\n': + escaped += "
"; + break; + case '&': + escaped += "&"; + break; + case '<': + escaped += "<"; + break; + case '>': + escaped += ">"; + break; + default: + if (static_cast(character) < 0x20) { + append_unicode_escape(escaped, static_cast(character)); + } else { + escaped += character; + } + break; + } + } + + return escaped; +} + +bool starts_with_csv_formula_marker(std::string_view value) { + std::size_t index = 0; + while (index < value.size() && value[index] == ' ') { + ++index; + } + + if (index >= value.size()) { + return false; + } + + switch (value[index]) { + case '=': + case '+': + case '-': + case '@': + case '\t': + case '\r': + case '\n': + return true; + default: + return false; + } +} + +std::string neutralize_csv_formula(std::string_view value) { + if (!starts_with_csv_formula_marker(value)) { + return std::string(value); + } + + std::string neutralized; + neutralized.reserve(value.size() + 1); + neutralized.push_back('\''); + neutralized.append(value); + return neutralized; +} + std::string escape_csv(std::string_view value) { - bool needs_quotes = value.find_first_of(",\"\n\r") != std::string_view::npos; + const auto safe_value = neutralize_csv_formula(value); + bool needs_quotes = safe_value.find_first_of(",\"\n\r") != std::string::npos; std::string escaped; - escaped.reserve(value.size() + 2); + escaped.reserve(safe_value.size() + 2); if (needs_quotes) { escaped.push_back('"'); } - for (const char character : value) { + for (const char character : safe_value) { if (character == '"') { escaped += "\"\""; } else { @@ -77,6 +175,40 @@ std::string escape_csv(std::string_view value) { return escaped; } +void write_text_file(const std::filesystem::path& path, std::string_view content) { + std::ofstream output(path); + if (!output) { + throw std::runtime_error("unable to open report output: " + path.string()); + } + + output << content; + if (!output) { + throw std::runtime_error("unable to write report output: " + path.string()); + } + + output.close(); + if (!output) { + throw std::runtime_error("unable to finalize report output: " + path.string()); + } +} + +void ensure_output_directory(const std::filesystem::path& path) { + std::error_code error; + std::filesystem::create_directories(path, error); + if (error) { + throw std::runtime_error( + "unable to create report output directory: " + path.string() + ": " + error.message()); + } + + if (!std::filesystem::is_directory(path, error)) { + if (error) { + throw std::runtime_error( + "unable to inspect report output directory: " + path.string() + ": " + error.message()); + } + throw std::runtime_error("report output path is not a directory: " + path.string()); + } +} + std::vector sorted_findings(const std::vector& findings) { auto ordered = findings; std::sort(ordered.begin(), ordered.end(), [](const Finding& left, const Finding& right) { @@ -171,6 +303,10 @@ std::string format_parse_success_percent(double rate) { return output.str(); } +std::size_t total_input_lines(const ParserQualityMetrics& quality) { + return quality.total_lines + quality.skipped_blank_lines; +} + std::string_view trim_left(std::string_view value) { while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { value.remove_prefix(1); @@ -372,7 +508,9 @@ std::string render_markdown_report(const ReportData& data) { output << "- Assume year: " << *data.parse_metadata.assume_year << '\n'; } output << "- Timezone present: " << (data.parse_metadata.timezone_present ? "true" : "false") << '\n'; + output << "- Total input lines: " << total_input_lines(data.parser_quality) << '\n'; output << "- Total lines: " << data.parser_quality.total_lines << '\n'; + output << "- Skipped blank lines: " << data.parser_quality.skipped_blank_lines << '\n'; output << "- Parsed lines: " << data.parser_quality.parsed_lines << '\n'; output << "- Unparsed lines: " << data.parser_quality.unparsed_lines << '\n'; output << "- Parse success rate: " << format_parse_success_percent(data.parser_quality.parse_success_rate) << '\n'; @@ -385,7 +523,7 @@ std::string render_markdown_report(const ReportData& data) { output << "| Host | Parsed Events | Findings | Warnings |\n"; output << "| --- | ---: | ---: | ---: |\n"; for (const auto& summary : host_summaries) { - output << "| " << summary.hostname + output << "| " << escape_markdown_table_cell(summary.hostname) << " | " << summary.parsed_event_count << " | " << summary.finding_count << " | " << summary.warning_count << " |\n"; @@ -400,12 +538,12 @@ std::string render_markdown_report(const ReportData& data) { output << "| Rule | Subject | Count | Window | Notes |\n"; output << "| --- | --- | ---: | --- | --- |\n"; for (const auto& finding : findings) { - output << "| " << to_string(finding.type) - << " | " << finding.subject + output << "| " << escape_markdown_table_cell(to_string(finding.type)) + << " | " << escape_markdown_table_cell(finding.subject) << " | " << finding.event_count << " | " << format_timestamp(finding.first_seen) << " -> " << format_timestamp(finding.last_seen) - << " | " << usernames_note(finding) << " |\n"; + << " | " << escape_markdown_table_cell(usernames_note(finding)) << " |\n"; } output << '\n'; } @@ -414,7 +552,7 @@ std::string render_markdown_report(const ReportData& data) { output << "| Event Type | Count |\n"; output << "| --- | ---: |\n"; for (const auto& [type, count] : event_counts) { - output << "| " << to_string(type) << " | " << count << " |\n"; + output << "| " << escape_markdown_table_cell(to_string(type)) << " | " << count << " |\n"; } output << '\n'; @@ -425,7 +563,7 @@ std::string render_markdown_report(const ReportData& data) { output << "| Unknown Pattern | Count |\n"; output << "| --- | ---: |\n"; for (const auto& entry : data.parser_quality.top_unknown_patterns) { - output << "| " << entry.pattern << " | " << entry.count << " |\n"; + output << "| " << escape_markdown_table_cell(entry.pattern) << " | " << entry.count << " |\n"; } output << '\n'; } @@ -437,7 +575,8 @@ std::string render_markdown_report(const ReportData& data) { output << "| Line | Reason |\n"; output << "| ---: | --- |\n"; for (const auto& warning : warnings) { - output << "| " << warning.line_number << " | " << warning.reason << " |\n"; + output << "| " << warning.line_number << " | " + << escape_markdown_table_cell(warning.reason) << " |\n"; } } @@ -460,7 +599,9 @@ std::string render_json_report(const ReportData& data) { } output << " \"timezone_present\": " << (data.parse_metadata.timezone_present ? "true" : "false") << ",\n"; output << " \"parser_quality\": {\n"; + output << " \"total_input_lines\": " << total_input_lines(data.parser_quality) << ",\n"; output << " \"total_lines\": " << data.parser_quality.total_lines << ",\n"; + output << " \"skipped_blank_lines\": " << data.parser_quality.skipped_blank_lines << ",\n"; output << " \"parsed_lines\": " << data.parser_quality.parsed_lines << ",\n"; output << " \"unparsed_lines\": " << data.parser_quality.unparsed_lines << ",\n"; output << " \"parse_success_rate\": " << format_parse_success_rate(data.parser_quality.parse_success_rate) << ",\n"; @@ -564,9 +705,10 @@ std::string render_warnings_csv(const ReportData& data) { std::ostringstream output; const auto warnings = sorted_warnings(data.warnings); - output << "kind,message\n"; + output << "kind,line_number,message\n"; for (const auto& warning : warnings) { output << "parse_warning," + << warning.line_number << ',' << escape_csv(warning.reason) << '\n'; } @@ -574,26 +716,18 @@ std::string render_warnings_csv(const ReportData& data) { } void write_reports(const ReportData& data, const std::filesystem::path& output_directory, bool emit_csv) { - std::filesystem::create_directories(output_directory); + ensure_output_directory(output_directory); - std::ofstream markdown_output(output_directory / "report.md"); - markdown_output << render_markdown_report(data); - - std::ofstream json_output(output_directory / "report.json"); - json_output << render_json_report(data); + write_text_file(output_directory / "report.md", render_markdown_report(data)); + write_text_file(output_directory / "report.json", render_json_report(data)); + const auto findings_csv_path = output_directory / "findings.csv"; + const auto warnings_csv_path = output_directory / "warnings.csv"; if (!emit_csv) { return; } - - const auto findings_csv_path = output_directory / "findings.csv"; - const auto warnings_csv_path = output_directory / "warnings.csv"; - - std::ofstream findings_csv_output(findings_csv_path); - findings_csv_output << render_findings_csv(data); - - std::ofstream warnings_csv_output(warnings_csv_path); - warnings_csv_output << render_warnings_csv(data); + write_text_file(findings_csv_path, render_findings_csv(data)); + write_text_file(warnings_csv_path, render_warnings_csv(data)); } } // namespace loglens diff --git a/src/signal.cpp b/src/signal.cpp index 16909ad..7b027a2 100644 --- a/src/signal.cpp +++ b/src/signal.cpp @@ -1,90 +1,106 @@ -#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::SshFailedKeyboardInteractive: + return SignalMapping{ + AuthSignalKind::SshFailedKeyboardInteractive, + config.ssh_failed_keyboard_interactive.counts_as_attempt_evidence, + config.ssh_failed_keyboard_interactive.counts_as_terminal_auth_failure, + false}; + case EventType::SshMaxAuthTries: + return SignalMapping{ + AuthSignalKind::SshMaxAuthTries, + config.ssh_max_auth_tries.counts_as_attempt_evidence, + config.ssh_max_auth_tries.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: + case EventType::SshAcceptedKeyboardInteractive: + case EventType::SudoAuthFailure: + case EventType::SudoPolicyDenied: + case EventType::SuAuthFailure: 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/src/signal.hpp b/src/signal.hpp index 3d7d446..1ca00da 100644 --- a/src/signal.hpp +++ b/src/signal.hpp @@ -13,6 +13,8 @@ enum class AuthSignalKind { SshFailedPassword, SshInvalidUser, SshFailedPublicKey, + SshFailedKeyboardInteractive, + SshMaxAuthTries, PamAuthFailure, SudoCommand, SudoSessionOpened @@ -27,6 +29,8 @@ struct AuthSignalConfig { AuthSignalBehavior ssh_failed_password{true, true}; AuthSignalBehavior ssh_invalid_user{true, true}; AuthSignalBehavior ssh_failed_publickey{true, true}; + AuthSignalBehavior ssh_failed_keyboard_interactive{true, true}; + AuthSignalBehavior ssh_max_auth_tries{true, true}; AuthSignalBehavior pam_auth_failure{true, false}; }; diff --git a/tests/fixtures/report_contracts/multi_host_syslog_legacy/warnings.csv b/tests/fixtures/report_contracts/multi_host_syslog_legacy/warnings.csv index c0f9236..14779cf 100644 --- a/tests/fixtures/report_contracts/multi_host_syslog_legacy/warnings.csv +++ b/tests/fixtures/report_contracts/multi_host_syslog_legacy/warnings.csv @@ -1,4 +1,4 @@ -kind,message -parse_warning,unrecognized auth pattern: pam_sss_unknown_user -parse_warning,unrecognized auth pattern: sshd_connection_closed_preauth -parse_warning,unrecognized auth pattern: sshd_timeout_or_disconnection +kind,line_number,message +parse_warning,12,unrecognized auth pattern: pam_sss_unknown_user +parse_warning,14,unrecognized auth pattern: sshd_connection_closed_preauth +parse_warning,15,unrecognized auth pattern: sshd_timeout_or_disconnection diff --git a/tests/fixtures/report_contracts/syslog_legacy/warnings.csv b/tests/fixtures/report_contracts/syslog_legacy/warnings.csv index 8fea094..1459da3 100644 --- a/tests/fixtures/report_contracts/syslog_legacy/warnings.csv +++ b/tests/fixtures/report_contracts/syslog_legacy/warnings.csv @@ -1,3 +1,3 @@ -kind,message -parse_warning,unrecognized auth pattern: sshd_connection_closed_preauth -parse_warning,unrecognized auth pattern: sshd_timeout_or_disconnection +kind,line_number,message +parse_warning,15,unrecognized auth pattern: sshd_connection_closed_preauth +parse_warning,16,unrecognized auth pattern: sshd_timeout_or_disconnection diff --git a/tests/test_cli.cpp b/tests/test_cli.cpp index 7a7db55..cb2b73f 100644 --- a/tests/test_cli.cpp +++ b/tests/test_cli.cpp @@ -102,11 +102,46 @@ int main(int argc, char* argv[]) { std::filesystem::remove_all(output_dir); std::filesystem::create_directories(output_dir); + const auto help_stdout = output_dir / "help_stdout.txt"; + const auto help_stderr = output_dir / "help_stderr.txt"; + const int help_exit = std::system(build_command( + quote_argument(loglens_exe) + " --help", + &help_stdout, + &help_stderr) + .c_str()); + const auto help_output = read_file(help_stdout); + expect(help_exit == 0, "expected --help to succeed"); + expect(help_output.find("Usage:") != std::string::npos, + "expected --help to print usage to stdout"); + expect(help_output.find("loglens --help") != std::string::npos, + "expected --help usage to mention help command"); + expect(help_output.find("loglens --version") != std::string::npos, + "expected --help usage to mention version command"); + expect(help_output.find("syslog|syslog-legacy|journalctl|journalctl-short-full") != std::string::npos, + "expected --help usage to mention supported mode aliases"); + expect(help_output.find("[--config ]") != std::string::npos, + "expected --help usage to mention analysis options"); + expect(help_output.find("--option=value") != std::string::npos, + "expected --help usage to mention equals-style option syntax"); + expect(read_file(help_stderr).empty(), "expected --help to keep stderr empty"); + + const auto version_stdout = output_dir / "version_stdout.txt"; + const auto version_stderr = output_dir / "version_stderr.txt"; + const int version_exit = std::system(build_command( + quote_argument(loglens_exe) + " --version", + &version_stdout, + &version_stderr) + .c_str()); + expect(version_exit == 0, "expected --version to succeed"); + expect(read_file(version_stdout) == "LogLens 0.1.0\n", + "expected --version to print project version to stdout"); + expect(read_file(version_stderr).empty(), "expected --version to keep stderr empty"); + const auto syslog_cli_out = output_dir / "syslog_cli"; std::filesystem::create_directories(syslog_cli_out); const int syslog_cli_exit = std::system(build_command( quote_argument(loglens_exe) - + " --mode syslog --year 2026 " + + " --mode syslog-legacy --year 2026 " + quote_argument(sample_log) + " " + quote_argument(syslog_cli_out)) .c_str()); @@ -120,11 +155,26 @@ int main(int argc, char* argv[]) { expect(!std::filesystem::exists(syslog_cli_out / "warnings.csv"), "did not expect warnings.csv without explicit csv flag"); + const auto leading_dash_log = output_dir / "-leading-dash-auth.log"; + std::filesystem::copy_file(sample_log, leading_dash_log, std::filesystem::copy_options::overwrite_existing); + const auto leading_dash_out = output_dir / "leading_dash_input"; + std::filesystem::create_directories(leading_dash_out); + const int leading_dash_exit = std::system(build_command( + quote_argument(loglens_exe) + + " --mode syslog-legacy --year 2026 -- " + + quote_argument(leading_dash_log) + + " " + quote_argument(leading_dash_out)) + .c_str()); + expect(leading_dash_exit == 0, "expected -- to allow input path beginning with dash"); + expect(read_file(leading_dash_out / "report.json").find("\"input_mode\": \"syslog_legacy\"") + != std::string::npos, + "expected leading-dash input run to produce syslog report"); + const auto csv_out = output_dir / "csv_run"; std::filesystem::create_directories(csv_out); const int csv_exit = std::system(build_command( quote_argument(loglens_exe) - + " --mode syslog --year 2026 --csv " + + " --mode=syslog-legacy --year=2026 --csv " + quote_argument(sample_log) + " " + quote_argument(csv_out)) .c_str()); @@ -137,28 +187,28 @@ int main(int argc, char* argv[]) { expect(findings_csv.find("brute_force,source_ip,203.0.113.10,5,2026-03-10 08:11:22,2026-03-10 08:18:05,,5 failed SSH attempts from 203.0.113.10 within 10 minutes.") != std::string::npos, "expected brute-force findings csv row"); - expect(warnings_csv.find("kind,message") == 0, "expected warnings csv header"); - expect(warnings_csv.find("parse_warning,unrecognized auth pattern: sshd_connection_closed_preauth") + expect(warnings_csv.find("kind,line_number,message") == 0, "expected warnings csv header"); + expect(warnings_csv.find("parse_warning,15,unrecognized auth pattern: sshd_connection_closed_preauth") != std::string::npos, "expected warning csv row"); - const auto stale_csv_out = output_dir / "stale_csv_run"; + const auto stale_csv_out = output_dir / "stale_csv"; std::filesystem::create_directories(stale_csv_out); { - std::ofstream findings_output(stale_csv_out / "findings.csv"); - findings_output << "keep-findings\n"; + std::ofstream output(stale_csv_out / "findings.csv"); + output << "keep-findings\n"; } { - std::ofstream warnings_output(stale_csv_out / "warnings.csv"); - warnings_output << "keep-warnings\n"; + std::ofstream output(stale_csv_out / "warnings.csv"); + output << "keep-warnings\n"; } const int stale_csv_exit = std::system(build_command( quote_argument(loglens_exe) + " --mode syslog --year 2026 " + quote_argument(sample_log) + " " + quote_argument(stale_csv_out)) - .c_str()); - expect(stale_csv_exit == 0, "expected non-csv run with pre-existing csv files to succeed"); + .c_str()); + expect(stale_csv_exit == 0, "expected non-csv run in directory with stale csv to succeed"); expect(read_file(stale_csv_out / "findings.csv") == "keep-findings\n", "expected non-csv run to preserve pre-existing findings.csv"); expect(read_file(stale_csv_out / "warnings.csv") == "keep-warnings\n", @@ -178,7 +228,7 @@ int main(int argc, char* argv[]) { std::filesystem::create_directories(journalctl_out); const int journalctl_exit = std::system(build_command( quote_argument(loglens_exe) - + " --mode journalctl-short-full " + + " --mode journalctl " + quote_argument(journalctl_log) + " " + quote_argument(journalctl_out)) .c_str()); @@ -202,12 +252,42 @@ int main(int argc, char* argv[]) { .c_str()); expect(missing_year_exit != 0, "expected syslog mode without year to fail"); + const auto short_year_out = output_dir / "short_year"; + const auto short_year_stderr = output_dir / "short_year_stderr.txt"; + std::filesystem::create_directories(short_year_out); + const int short_year_exit = std::system(build_command( + quote_argument(loglens_exe) + + " --mode syslog --year 26 " + + quote_argument(sample_log) + + " " + quote_argument(short_year_out), + nullptr, + &short_year_stderr) + .c_str()); + expect(short_year_exit != 0, "expected short --year value to fail"); + expect(read_file(short_year_stderr).find("invalid year value: 26") != std::string::npos, + "expected short --year failure message"); + + const auto nondigit_year_out = output_dir / "nondigit_year"; + const auto nondigit_year_stderr = output_dir / "nondigit_year_stderr.txt"; + std::filesystem::create_directories(nondigit_year_out); + const int nondigit_year_exit = std::system(build_command( + quote_argument(loglens_exe) + + " --mode=syslog --year=20x6 " + + quote_argument(sample_log) + + " " + quote_argument(nondigit_year_out), + nullptr, + &nondigit_year_stderr) + .c_str()); + expect(nondigit_year_exit != 0, "expected non-digit --year value to fail"); + expect(read_file(nondigit_year_stderr).find("invalid year value: 20x6") != std::string::npos, + "expected non-digit --year failure message"); + const auto invalid_config = output_dir / "invalid_config.json"; { std::ofstream output(invalid_config); output << "{\n" << " \"input_mode\": \"syslog_legacy\",\n" - << " \"timestamp\": { \"assume_year\": \"bad\" },\n" + << " \"timestamp\": { \"assume_year\": 26 },\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" @@ -221,14 +301,19 @@ int main(int argc, char* argv[]) { } const auto invalid_out = output_dir / "invalid_config_run"; + const auto invalid_stderr = output_dir / "invalid_config_stderr.txt"; std::filesystem::create_directories(invalid_out); const int invalid_exit = std::system(build_command( quote_argument(loglens_exe) + " --config " + quote_argument(invalid_config) + " " + quote_argument(sample_log) - + " " + quote_argument(invalid_out)) + + " " + quote_argument(invalid_out), + nullptr, + &invalid_stderr) .c_str()); expect(invalid_exit != 0, "expected invalid config CLI run to fail"); + expect(read_file(invalid_stderr).find("timestamp.assume_year must be a four-digit year") != std::string::npos, + "expected invalid config year failure message"); return 0; } diff --git a/tests/test_detector.cpp b/tests/test_detector.cpp index 7bcc49f..0804791 100644 --- a/tests/test_detector.cpp +++ b/tests/test_detector.cpp @@ -1,85 +1,85 @@ -#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"); } @@ -98,87 +98,103 @@ std::vector build_pam_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: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"); -} - + "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 sshd[1239]: Failed keyboard-interactive/pam for root from 203.0.113.12 port 51061 ssh2\n" + "Mar 10 08:18:07 example-host sshd[1240]: maximum authentication attempts exceeded for root from 203.0.113.13 port 51062 ssh2 [preauth]\n" + "Mar 10 08:18:08 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() == 4, "expected four 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* keyboard_interactive = find_signal(signals, loglens::AuthSignalKind::SshFailedKeyboardInteractive); + expect(keyboard_interactive != nullptr, "expected keyboard-interactive signal"); + expect(keyboard_interactive->counts_as_attempt_evidence, + "expected keyboard-interactive to count as attempt evidence"); + expect(keyboard_interactive->counts_as_terminal_auth_failure, + "expected keyboard-interactive to count as terminal auth failure"); + + const auto* max_auth_tries = find_signal(signals, loglens::AuthSignalKind::SshMaxAuthTries); + expect(max_auth_tries != nullptr, "expected max-auth-tries signal"); + expect(max_auth_tries->counts_as_attempt_evidence, + "expected max-auth-tries to count as attempt evidence"); + expect(max_auth_tries->counts_as_terminal_auth_failure, + "expected max-auth-tries 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"); } @@ -199,168 +215,168 @@ void test_accepted_publickey_success_stays_out_of_failure_signals() { 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 - + + 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(); @@ -369,10 +385,10 @@ int main() { 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 44fd266..9ee2169 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -1,20 +1,20 @@ -#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, @@ -26,77 +26,77 @@ loglens::AuthLogParser make_journalctl_parser() { 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"); -} - + +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"); } @@ -114,39 +114,127 @@ void test_accepted_publickey_success_event() { "expected accepted publickey event type"); } +void test_accepted_keyboard_interactive_success_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 11 10:00:08 example-host sshd[2103]: Accepted keyboard-interactive/pam for dave from 203.0.113.76 port 53003 ssh2", + 4); + + expect(event.has_value(), "expected accepted keyboard-interactive event"); + expect(event->username == "dave", "expected accepted keyboard-interactive username"); + expect(event->source_ip == "203.0.113.76", "expected accepted keyboard-interactive source ip"); + expect(event->event_type == loglens::EventType::SshAcceptedKeyboardInteractive, + "expected accepted keyboard-interactive event type"); +} + void test_sudo_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", 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.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_sudo_auth_failure_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:21:40 example-host sudo[1241]: alice : 1 incorrect password attempt ; TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/id", + 4); + + expect(event.has_value(), "expected sudo auth failure event"); + expect(event->program == "sudo", "expected sudo failure program"); + expect(event->pid.has_value() && *event->pid == 1241, "expected sudo failure pid"); + expect(event->username == "alice", "expected sudo failure actor username"); + expect(event->event_type == loglens::EventType::SudoAuthFailure, "expected sudo auth failure type"); +} + +void test_sudo_policy_denied_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:21:55 example-host sudo[1242]: bob : user NOT in sudoers ; TTY=pts/1 ; PWD=/home/bob ; USER=root ; COMMAND=/usr/bin/id", + 4); + + expect(event.has_value(), "expected sudo policy denied event"); + expect(event->program == "sudo", "expected sudo policy program"); + expect(event->username == "bob", "expected sudo policy actor username"); + expect(event->event_type == loglens::EventType::SudoPolicyDenied, "expected sudo policy denied type"); +} + +void test_su_auth_failure_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:23:01 example-host su[1243]: FAILED SU (to root) carol on pts/1", + 4); + + expect(event.has_value(), "expected su auth failure event"); + expect(event->program == "su", "expected su failure program"); + expect(event->username == "carol", "expected su failure actor username"); + expect(event->event_type == loglens::EventType::SuAuthFailure, "expected su auth failure type"); +} + +void test_su_success_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:23:25 example-host su[1244]: Successful su for root by dave", + 4); + + expect(event.has_value(), "expected su success event"); + expect(event->program == "su", "expected su success program"); + expect(event->username == "dave", "expected su success actor username"); + expect(event->event_type == loglens::EventType::SessionOpened, "expected su success session-opened 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_failed_keyboard_interactive_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:27:18 example-host sshd[1244]: Failed keyboard-interactive/pam for eve from 203.0.113.77 port 51241 ssh2", + 5); + + expect(event.has_value(), "expected failed keyboard-interactive event"); + expect(event->username == "eve", "expected parsed keyboard-interactive username"); + expect(event->source_ip == "203.0.113.77", "expected parsed keyboard-interactive source ip"); + expect(event->event_type == loglens::EventType::SshFailedKeyboardInteractive, + "expected ssh keyboard-interactive failure type"); +} + +void test_max_auth_tries_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:27:25 example-host sshd[1245]: maximum authentication attempts exceeded for frank from 203.0.113.78 port 51242 ssh2 [preauth]", + 5); + + expect(event.has_value(), "expected max-auth-tries event"); + expect(event->username == "frank", "expected parsed max-auth-tries username"); + expect(event->source_ip == "203.0.113.78", "expected parsed max-auth-tries source ip"); + expect(event->event_type == loglens::EventType::SshMaxAuthTries, + "expected ssh max-auth-tries failure 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"); } @@ -169,30 +257,46 @@ 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_input_mode_aliases() { + expect(loglens::parse_input_mode("syslog") == loglens::InputMode::SyslogLegacy, + "expected syslog mode alias"); + expect(loglens::parse_input_mode("syslog-legacy") == loglens::InputMode::SyslogLegacy, + "expected syslog-legacy mode alias"); + expect(loglens::parse_input_mode("syslog_legacy") == loglens::InputMode::SyslogLegacy, + "expected syslog_legacy mode alias"); + expect(loglens::parse_input_mode("journalctl") == loglens::InputMode::JournalctlShortFull, + "expected journalctl mode alias"); + expect(loglens::parse_input_mode("journalctl-short-full") == loglens::InputMode::JournalctlShortFull, + "expected journalctl-short-full mode alias"); + expect(loglens::parse_input_mode("journalctl_short_full") == loglens::InputMode::JournalctlShortFull, + "expected journalctl_short_full mode alias"); + expect(!loglens::parse_input_mode("unknown").has_value(), "expected unknown mode to be rejected"); +} + void test_syslog_auth_family_fixture_file() { const auto parser = make_syslog_parser(); const auto result = parser.parse_file(asset_path("parser_auth_families_syslog.log")); @@ -305,90 +409,120 @@ 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() { - 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"); -} - + + 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_stream_tracks_skipped_blank_lines() { + const auto parser = make_syslog_parser(); + std::istringstream input( + "\n" + " \t\n" + "Mar 10 08:20:10 example-host sshd[1240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2\n"); + + const auto result = parser.parse_stream(input); + + expect(result.events.size() == 1, "expected one parsed event after blank lines"); + expect(result.warnings.empty(), "did not expect warnings for skipped blank lines"); + expect(result.quality.skipped_blank_lines == 2, "expected two skipped blank lines"); + expect(result.quality.total_lines == 1, "expected total_lines to keep counting analyzed nonblank lines"); + expect(result.quality.parsed_lines == 1, "expected parsed line count to ignore blank lines"); + expect(result.quality.unparsed_lines == 0, "expected unparsed line count to ignore blank lines"); + expect(result.quality.parse_success_rate == 1.0, "expected parse success rate to ignore blank lines"); +} + +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_journalctl_rejects_empty_fractional_seconds() { + const auto parser = make_journalctl_parser(); + std::string error; + const auto event = parser.parse_line( + "Tue 2026-03-10 08:11:22. UTC example-host sshd[2234]: Failed password for root from 203.0.113.10 port 51022 ssh2", + 10, + &error); + + expect(!event.has_value(), "expected empty fractional seconds to be rejected"); + expect(error == "invalid time token", "expected invalid time token error"); +} + 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() == 8, "expected eight recognized syslog fixture events"); + expect(result.events.size() == 15, "expected fifteen recognized syslog fixture events"); expect(result.warnings.size() == 8, "expected eight syslog fixture warnings"); - expect(result.quality.total_lines == 16, "expected sixteen syslog fixture lines"); - expect(result.quality.parsed_lines == 8, "expected eight parsed syslog fixture lines"); + expect(result.quality.total_lines == 23, "expected twenty-three syslog fixture lines"); + expect(result.quality.parsed_lines == 15, "expected fifteen parsed syslog fixture lines"); expect(result.quality.unparsed_lines == 8, "expected eight unparsed syslog fixture lines"); - expect_close(result.quality.parse_success_rate, 0.5, 1e-9, "expected syslog fixture parse success rate"); + expect_close(result.quality.parse_success_rate, 15.0 / 23.0, 1e-9, "expected syslog fixture parse success rate"); expect(result.events[0].event_type == loglens::EventType::SshInvalidUser, "expected invalid-user failed password"); expect(result.events[1].event_type == loglens::EventType::SshFailedPublicKey, "expected failed publickey variant"); @@ -402,6 +536,27 @@ void test_syslog_fixture_matrix_file() { expect(result.events[5].username == "bob", "expected su-l session actor username"); expect(result.events[6].username == "alice", "expected accepted password username"); expect(result.events[7].username == "carol", "expected accepted publickey username"); + expect(result.events[8].event_type == loglens::EventType::SshAcceptedKeyboardInteractive, + "expected accepted keyboard-interactive variant"); + expect(result.events[8].username == "dave", "expected accepted keyboard-interactive username"); + expect(result.events[9].event_type == loglens::EventType::SudoAuthFailure, + "expected sudo auth failure variant"); + expect(result.events[9].username == "alice", "expected sudo auth failure username"); + expect(result.events[10].event_type == loglens::EventType::SudoPolicyDenied, + "expected sudo policy denied variant"); + expect(result.events[10].username == "bob", "expected sudo policy denied username"); + expect(result.events[11].event_type == loglens::EventType::SuAuthFailure, + "expected su auth failure variant"); + expect(result.events[11].username == "carol", "expected su auth failure username"); + expect(result.events[12].event_type == loglens::EventType::SessionOpened, + "expected su success session-opened variant"); + expect(result.events[12].username == "dave", "expected su success actor username"); + expect(result.events[13].event_type == loglens::EventType::SshFailedKeyboardInteractive, + "expected failed keyboard-interactive variant"); + expect(result.events[13].username == "eve", "expected failed keyboard-interactive username"); + expect(result.events[14].event_type == loglens::EventType::SshMaxAuthTries, + "expected max-auth-tries variant"); + expect(result.events[14].username == "frank", "expected max-auth-tries username"); expect(result.quality.top_unknown_patterns.size() == 4, "expected four unknown syslog buckets"); expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", @@ -417,19 +572,19 @@ void test_syslog_fixture_matrix_file() { "expected unsupported sshd syslog bucket"); expect(result.quality.top_unknown_patterns[3].count == 1, "expected one unsupported sshd 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() == 8, "expected eight recognized journalctl fixture events"); + expect(result.events.size() == 15, "expected fifteen recognized journalctl fixture events"); expect(result.warnings.size() == 8, "expected eight journalctl fixture warnings"); - expect(result.quality.total_lines == 16, "expected sixteen journalctl fixture lines"); - expect(result.quality.parsed_lines == 8, "expected eight parsed journalctl fixture lines"); + expect(result.quality.total_lines == 23, "expected twenty-three journalctl fixture lines"); + expect(result.quality.parsed_lines == 15, "expected fifteen parsed journalctl fixture lines"); expect(result.quality.unparsed_lines == 8, "expected eight unparsed journalctl fixture lines"); - expect_close(result.quality.parse_success_rate, 0.5, 1e-9, "expected journalctl fixture parse success rate"); + expect_close(result.quality.parse_success_rate, 15.0 / 23.0, 1e-9, "expected journalctl fixture parse success rate"); expect(result.events[0].event_type == loglens::EventType::SshInvalidUser, "expected journalctl invalid-user failed password"); expect(result.events[1].event_type == loglens::EventType::SshFailedPublicKey, "expected journalctl failed publickey variant"); @@ -439,6 +594,20 @@ void test_journalctl_fixture_matrix_file() { expect(result.events[5].event_type == loglens::EventType::SessionOpened, "expected journalctl su-l session-opened variant"); expect(result.events[6].event_type == loglens::EventType::SshAcceptedPassword, "expected journalctl accepted password variant"); expect(result.events[7].event_type == loglens::EventType::SshAcceptedPublicKey, "expected journalctl accepted publickey variant"); + expect(result.events[8].event_type == loglens::EventType::SshAcceptedKeyboardInteractive, + "expected journalctl accepted keyboard-interactive variant"); + expect(result.events[9].event_type == loglens::EventType::SudoAuthFailure, + "expected journalctl sudo auth failure variant"); + expect(result.events[10].event_type == loglens::EventType::SudoPolicyDenied, + "expected journalctl sudo policy denied variant"); + expect(result.events[11].event_type == loglens::EventType::SuAuthFailure, + "expected journalctl su auth failure variant"); + expect(result.events[12].event_type == loglens::EventType::SessionOpened, + "expected journalctl su success session-opened variant"); + expect(result.events[13].event_type == loglens::EventType::SshFailedKeyboardInteractive, + "expected journalctl failed keyboard-interactive variant"); + expect(result.events[14].event_type == loglens::EventType::SshMaxAuthTries, + "expected journalctl max-auth-tries variant"); expect(result.quality.top_unknown_patterns.size() == 4, "expected four unknown journalctl buckets"); expect(result.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth", @@ -454,27 +623,37 @@ void test_journalctl_fixture_matrix_file() { "expected unsupported sshd journalctl bucket"); expect(result.quality.top_unknown_patterns[3].count == 1, "expected one unsupported sshd journalctl line"); } - -} // namespace - + +} // namespace + int main() { test_invalid_user_failure(); test_standard_failure(); test_success_event(); test_accepted_publickey_success_event(); + test_accepted_keyboard_interactive_success_event(); test_sudo_event(); + test_sudo_auth_failure_event(); + test_sudo_policy_denied_event(); + test_su_auth_failure_event(); + test_su_success_event(); test_failed_publickey_event(); + test_failed_keyboard_interactive_event(); + test_max_auth_tries_event(); test_pam_auth_failure_event(); test_pam_sss_received_failure_event(); test_session_opened_event(); test_journalctl_short_full_event(); + test_input_mode_aliases(); 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_stream_tracks_skipped_blank_lines(); + test_journalctl_metadata(); + test_journalctl_rejects_empty_fractional_seconds(); + test_syslog_fixture_matrix_file(); + test_journalctl_fixture_matrix_file(); + return 0; +} diff --git a/tests/test_report.cpp b/tests/test_report.cpp new file mode 100644 index 0000000..8dabc2e --- /dev/null +++ b/tests/test_report.cpp @@ -0,0 +1,257 @@ +#include "report.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +std::chrono::sys_seconds timestamp_at_minute(int minute_value) { + using namespace std::chrono; + return sys_days{year{2026} / month{3} / day{10}} + hours{8} + minutes{minute_value}; +} + +std::string read_file(const std::filesystem::path& path) { + std::ifstream input(path); + if (!input) { + throw std::runtime_error("unable to read file: " + path.string()); + } + + return std::string((std::istreambuf_iterator(input)), std::istreambuf_iterator()); +} + +loglens::ReportData make_report_data() { + loglens::ReportData data; + data.input_path = std::filesystem::path{"assets/sample_auth.log"}; + data.parse_metadata.input_mode = loglens::InputMode::SyslogLegacy; + data.parse_metadata.assume_year = 2026; + data.parse_metadata.timezone_present = false; + data.parser_quality.total_lines = 1; + data.parser_quality.unparsed_lines = 1; + data.parser_quality.top_unknown_patterns.push_back({"bad_pattern", 1}); + return data; +} + +void test_markdown_table_cells_escape_user_controlled_values() { + auto data = make_report_data(); + + loglens::Finding finding; + finding.type = loglens::FindingType::SudoBurst; + finding.subject_kind = "username"; + finding.subject = "ali|ce"; + finding.event_count = 3; + finding.first_seen = timestamp_at_minute(21); + finding.last_seen = timestamp_at_minute(24); + finding.usernames = {"ali|ce", "bob"}; + finding.summary = "summary | & more"; + data.findings.push_back(finding); + data.warnings.push_back({1, "bad | value\nnext & more"}); + + const auto markdown = loglens::render_markdown_report(data); + + expect(markdown.find("| sudo_burst | ali\\|ce | 3 |") != std::string::npos, + "expected markdown finding subject to escape table pipes"); + expect(markdown.find("summary \\| <raw> & more Usernames: ali\\|ce, bob<root>") + != std::string::npos, + "expected markdown finding notes to escape table and html-sensitive characters"); + expect(markdown.find("| 1 | bad \\| value
next <tag> & more |") != std::string::npos, + "expected markdown warning reason to escape table pipes and newlines"); +} + +void test_json_escapes_generic_control_characters() { + auto data = make_report_data(); + + std::string reason = "bad "; + reason.push_back('\x01'); + reason += " \"quote\""; + data.warnings.push_back({7, reason}); + + const auto json = loglens::render_json_report(data); + + expect(json.find('\x01') == std::string::npos, "expected raw control character to be absent from json"); + expect(json.find("\"reason\": \"bad \\u0001 \\\"quote\\\"\"") != std::string::npos, + "expected json warning reason to use valid escapes"); +} + +void test_reports_include_total_input_line_count() { + auto data = make_report_data(); + data.parser_quality.total_lines = 3; + data.parser_quality.skipped_blank_lines = 2; + + const auto markdown = loglens::render_markdown_report(data); + const auto json = loglens::render_json_report(data); + + expect(markdown.find("Total input lines: 5") != std::string::npos, + "expected markdown report to include total input line count"); + expect(json.find("\"total_input_lines\": 5") != std::string::npos, + "expected json report to include total input line count"); +} + +void test_csv_neutralizes_formula_like_fields() { + auto data = make_report_data(); + + loglens::Finding finding; + finding.type = loglens::FindingType::SudoBurst; + finding.subject_kind = "username"; + finding.subject = "=alice"; + finding.event_count = 3; + finding.first_seen = timestamp_at_minute(21); + finding.last_seen = timestamp_at_minute(24); + finding.usernames = {"+bob", "-carol", "@dave"}; + finding.summary = " @summary"; + data.findings.push_back(finding); + data.warnings.push_back({2, "=warning"}); + + const auto findings_csv = loglens::render_findings_csv(data); + const auto warnings_csv = loglens::render_warnings_csv(data); + + expect(findings_csv.find("sudo_burst,username,'=alice,3,") != std::string::npos, + "expected formula-like finding subject to be neutralized"); + expect(findings_csv.find(",'+bob;-carol;@dave,' @summary") != std::string::npos, + "expected formula-like usernames and summary to be neutralized"); + expect(warnings_csv.find("parse_warning,2,'=warning") != std::string::npos, + "expected formula-like warning reason to be neutralized"); +} + +void test_write_reports_fails_when_report_path_is_directory() { + const auto output_directory = std::filesystem::current_path() / "report_write_failure_test"; + std::filesystem::remove_all(output_directory); + std::filesystem::create_directories(output_directory / "report.md"); + + bool threw = false; + std::string message; + try { + loglens::write_reports(make_report_data(), output_directory); + } catch (const std::runtime_error& error) { + threw = true; + message = error.what(); + } + + std::filesystem::remove_all(output_directory); + + expect(threw, "expected write_reports to fail when report.md cannot be opened"); + expect(message.find("report.md") != std::string::npos, + "expected write_reports failure to mention report.md"); +} + +void test_write_reports_writes_default_report_files() { + const auto output_directory = std::filesystem::current_path() / "report_success_test"; + std::filesystem::remove_all(output_directory); + + loglens::write_reports(make_report_data(), output_directory); + + const auto markdown = read_file(output_directory / "report.md"); + const auto json = read_file(output_directory / "report.json"); + + expect(markdown.find("# LogLens Report") != std::string::npos, + "expected default report run to write markdown report"); + expect(markdown.find("Total input lines: 1") != std::string::npos, + "expected default markdown report to include total input line count"); + expect(markdown.find("Skipped blank lines: 0") != std::string::npos, + "expected default markdown report to include skipped blank line count"); + expect(json.find("\"tool\": \"LogLens\"") != std::string::npos, + "expected default report run to write json report"); + expect(json.find("\"total_input_lines\": 1") != std::string::npos, + "expected default json report to include total input line count"); + expect(json.find("\"skipped_blank_lines\": 0") != std::string::npos, + "expected default json report to include skipped blank line count"); + expect(!std::filesystem::exists(output_directory / "findings.csv"), + "did not expect findings.csv without csv flag"); + expect(!std::filesystem::exists(output_directory / "warnings.csv"), + "did not expect warnings.csv without csv flag"); + + std::filesystem::remove_all(output_directory); +} + +void test_write_reports_fails_when_output_path_is_file() { + const auto output_path = std::filesystem::current_path() / "report_output_file_test"; + std::filesystem::remove(output_path); + { + std::ofstream output(output_path); + output << "not a directory\n"; + } + + bool threw = false; + std::string message; + try { + loglens::write_reports(make_report_data(), output_path); + } catch (const std::runtime_error& error) { + threw = true; + message = error.what(); + } + + std::filesystem::remove(output_path); + + expect(threw, "expected write_reports to fail when output path is a file"); + expect(message.find("report_output_file_test") != std::string::npos, + "expected output directory failure to mention the output path"); +} + +void test_write_reports_preserves_existing_csv_when_csv_is_disabled() { + const auto output_directory = std::filesystem::current_path() / "report_existing_csv_preservation_test"; + std::filesystem::remove_all(output_directory); + std::filesystem::create_directories(output_directory); + + { + std::ofstream output(output_directory / "findings.csv"); + output << "keep-findings\n"; + } + { + std::ofstream output(output_directory / "warnings.csv"); + output << "keep-warnings\n"; + } + + loglens::write_reports(make_report_data(), output_directory, false); + + expect(read_file(output_directory / "findings.csv") == "keep-findings\n", + "expected existing findings.csv to be preserved when csv is disabled"); + expect(read_file(output_directory / "warnings.csv") == "keep-warnings\n", + "expected existing warnings.csv to be preserved when csv is disabled"); + + std::filesystem::remove_all(output_directory); +} + +void test_write_reports_reports_csv_write_failure() { + const auto output_directory = std::filesystem::current_path() / "report_csv_write_failure_test"; + std::filesystem::remove_all(output_directory); + std::filesystem::create_directories(output_directory / "findings.csv" / "nested"); + + bool threw = false; + std::string message; + try { + loglens::write_reports(make_report_data(), output_directory, true); + } catch (const std::runtime_error& error) { + threw = true; + message = error.what(); + } + + std::filesystem::remove_all(output_directory); + + expect(threw, "expected write_reports to fail when findings.csv cannot be written"); + expect(message.find("findings.csv") != std::string::npos, + "expected csv write failure to mention findings.csv"); +} + +} // namespace + +int main() { + test_markdown_table_cells_escape_user_controlled_values(); + test_json_escapes_generic_control_characters(); + test_reports_include_total_input_line_count(); + test_csv_neutralizes_formula_like_fields(); + test_write_reports_fails_when_report_path_is_directory(); + test_write_reports_writes_default_report_files(); + test_write_reports_fails_when_output_path_is_file(); + test_write_reports_preserves_existing_csv_when_csv_is_disabled(); + test_write_reports_reports_csv_write_failure(); + return 0; +}