From 0d1775ac81851bc1084362347e2475da2c964a5b Mon Sep 17 00:00:00 2001 From: stacknil Date: Thu, 21 May 2026 15:38:17 +0800 Subject: [PATCH 1/4] Add reviewer brief --- docs/reviewer-brief.md | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/reviewer-brief.md diff --git a/docs/reviewer-brief.md b/docs/reviewer-brief.md new file mode 100644 index 0000000..0f3bdd3 --- /dev/null +++ b/docs/reviewer-brief.md @@ -0,0 +1,56 @@ +# Reviewer brief + +## Problem + +Linux auth logs are noisy, format-sensitive, and easy to parse incorrectly. Reviewers often see detectors that claim findings without making parser limits or coverage visible. + +## What it does + +`LogLens` is a C++20 offline CLI for Linux authentication evidence. It parses `auth.log` / `secure` style syslog input and `journalctl --output=short-full` style input, normalizes the evidence, applies small rule-based detections, and emits deterministic Markdown and JSON reports with parser coverage telemetry. + +## Quick 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 +``` + +## Sample output + +The bundled sanitized sample produces `out/report.md` and `out/report.json`. + +The current README-documented summary excerpt is: + +- input mode: `syslog_legacy` +- parsed events: `14` +- findings: `3` +- parser warnings: `2` + +When `--csv` is enabled, the CLI also emits `findings.csv` and `warnings.csv`. + +## What this proves + +- C++ implementation discipline for a defensive CLI instead of a throwaway script +- parser observability, not just detection output +- deterministic report generation with stable review artifacts +- repository hygiene through CI, tests, and CodeQL + +## Safety / boundaries + +- offline log review only +- defensive and public-safe scope +- no exploitation, persistence, credential attack automation, or live collection +- findings are triage aids, not incident verdicts + +## Limitations + +- parser coverage is intentionally narrow and auth-family focused +- no cross-host correlation or SIEM-like aggregation +- `syslog_legacy` requires an explicit year +- rules are threshold-based and conservative + +## Next milestone + +Broaden supported auth patterns and keep parser-coverage evidence as visible as the finding output. From 069d8025d98a2487bd33c4eab09927344bbbd3a7 Mon Sep 17 00:00:00 2001 From: stacknil Date: Thu, 21 May 2026 22:07:43 +0800 Subject: [PATCH 2/4] feat: expand parser coverage and reviewer docs --- .gitignore | 2 + CHANGELOG.md | 5 +- CMakeLists.txt | 13 + CMakePresets.json | 61 +++ README.md | 171 +++--- ...er_auth_families_journalctl_short_full.log | 13 + assets/parser_auth_families_syslog.log | 13 + ...r_fixture_matrix_journalctl_short_full.log | 11 + assets/parser_fixture_matrix_syslog.log | 11 + assets/sample_config.json | 8 + docs/dev-setup.md | 67 +++ docs/parser-contract.md | 88 ++++ docs/reviewer-brief.md | 8 + docs/reviewer-path.md | 45 ++ src/config.cpp | 7 + src/event.hpp | 23 +- src/main.cpp | 129 ++++- src/parser.cpp | 279 +++++++++- src/parser.hpp | 1 + src/report.cpp | 488 +++++++++++++++++- src/report.hpp | 6 +- src/signal.cpp | 17 + src/signal.hpp | 4 + .../journalctl_short_full/input.log | 16 + .../journalctl_short_full/report.json | 64 +++ .../journalctl_short_full/report.md | 48 ++ .../input.log | 15 + .../report.json | 90 ++++ .../report.md | 56 ++ .../multi_host_syslog_legacy/findings.csv | 4 + .../multi_host_syslog_legacy/input.log | 15 + .../multi_host_syslog_legacy/report.json | 91 ++++ .../multi_host_syslog_legacy/report.md | 57 ++ .../multi_host_syslog_legacy/warnings.csv | 4 + .../syslog_legacy/findings.csv | 4 + .../report_contracts/syslog_legacy/input.log | 16 + .../syslog_legacy/report.json | 65 +++ .../report_contracts/syslog_legacy/report.md | 49 ++ .../syslog_legacy/warnings.csv | 3 + tests/test_cli.cpp | 137 ++++- tests/test_detector.cpp | 44 +- tests/test_parser.cpp | 371 ++++++++++++- tests/test_report.cpp | 253 +++++++++ tests/test_report_contracts.cpp | 339 ++++++++++++ 44 files changed, 3079 insertions(+), 132 deletions(-) create mode 100644 CMakePresets.json create mode 100644 assets/parser_auth_families_journalctl_short_full.log create mode 100644 assets/parser_auth_families_syslog.log create mode 100644 docs/dev-setup.md create mode 100644 docs/parser-contract.md create mode 100644 docs/reviewer-path.md create mode 100644 tests/fixtures/report_contracts/journalctl_short_full/input.log create mode 100644 tests/fixtures/report_contracts/journalctl_short_full/report.json create mode 100644 tests/fixtures/report_contracts/journalctl_short_full/report.md create mode 100644 tests/fixtures/report_contracts/multi_host_journalctl_short_full/input.log create mode 100644 tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.json create mode 100644 tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.md create mode 100644 tests/fixtures/report_contracts/multi_host_syslog_legacy/findings.csv create mode 100644 tests/fixtures/report_contracts/multi_host_syslog_legacy/input.log create mode 100644 tests/fixtures/report_contracts/multi_host_syslog_legacy/report.json create mode 100644 tests/fixtures/report_contracts/multi_host_syslog_legacy/report.md create mode 100644 tests/fixtures/report_contracts/multi_host_syslog_legacy/warnings.csv create mode 100644 tests/fixtures/report_contracts/syslog_legacy/findings.csv create mode 100644 tests/fixtures/report_contracts/syslog_legacy/input.log create mode 100644 tests/fixtures/report_contracts/syslog_legacy/report.json create mode 100644 tests/fixtures/report_contracts/syslog_legacy/report.md create mode 100644 tests/fixtures/report_contracts/syslog_legacy/warnings.csv create mode 100644 tests/test_report.cpp create mode 100644 tests/test_report_contracts.cpp diff --git a/.gitignore b/.gitignore index eeace22..915b2e0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ out/ report.md report.json *.exe +!tests/fixtures/report_contracts/**/report.md +!tests/fixtures/report_contracts/**/report.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 482a53a..bdf51f2 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 79fd0e6..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( @@ -42,4 +47,12 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/assets/sample_config.json ${CMAKE_CURRENT_BINARY_DIR}/cli_test_output ) + + add_executable(test_report_contracts tests/test_report_contracts.cpp) + add_test( + NAME report_contracts + COMMAND test_report_contracts + $ + ${CMAKE_CURRENT_BINARY_DIR}/report_contract_output + ) endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..e134a5b --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,61 @@ +{ + "version": 3, + "cmakeMinimumRequired": { + "major": 3, + "minor": 20, + "patch": 0 + }, + "configurePresets": [ + { + "name": "dev-debug", + "displayName": "Local Debug", + "description": "Default local debug build with tests enabled.", + "binaryDir": "${sourceDir}/build/dev-debug", + "cacheVariables": { + "BUILD_TESTING": "ON", + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "ci-release", + "displayName": "CI-style Release", + "description": "Release build that matches the repository CI flags.", + "binaryDir": "${sourceDir}/build/ci-release", + "cacheVariables": { + "BUILD_TESTING": "ON", + "CMAKE_BUILD_TYPE": "Release" + } + } + ], + "buildPresets": [ + { + "name": "dev-debug", + "configurePreset": "dev-debug", + "configuration": "Debug" + }, + { + "name": "ci-release", + "configurePreset": "ci-release", + "configuration": "Release" + } + ], + "testPresets": [ + { + "name": "dev-debug", + "configurePreset": "dev-debug", + "configuration": "Debug", + "output": { + "outputOnFailure": true + } + }, + { + "name": "ci-release", + "configurePreset": "ci-release", + "configuration": "Release", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/README.md b/README.md index 07a59a2..83163c1 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ C++20 defensive log analysis CLI for Linux authentication logs, with parser coverage telemetry, configurable detection rules, CI, and CodeQL. -It parses `auth.log` / `secure`-style syslog input and `journalctl --output=short-full`-style input, normalizes authentication evidence, applies configurable rule-based detections, and emits deterministic Markdown and JSON reports. +It parses `auth.log` / `secure`-style syslog input and `journalctl --output=short-full`-style input, normalizes authentication evidence, applies configurable rule-based detections, and emits deterministic Markdown and JSON reports, with optional CSV exports for findings and warnings. ## Project Status 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,52 +60,82 @@ LogLens currently detects: - One IP trying multiple usernames within 15 minutes - Bursty sudo activity from the same user within 5 minutes -LogLens currently parses and reports these additional auth patterns: - -- `Failed publickey` SSH failures, which count toward SSH brute-force detection by default -- `pam_unix(...:auth): authentication failure` -- `pam_unix(...:session): session opened` - -LogLens also tracks parser coverage telemetry for unsupported or malformed lines, including: - -- `total_lines` -- `parsed_lines` -- `unparsed_lines` -- `parse_success_rate` -- `top_unknown_patterns` - -LogLens does not currently detect: - -- Lateral movement -- MFA abuse -- SSH key misuse -- PAM-specific failures beyond the parsed sample patterns -- Cross-file or cross-host correlation - -## Build - -```bash -cmake -S . -B build -cmake --build build -ctest --test-dir build --output-on-failure -``` - -## Run - -```bash -./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 -``` - -The CLI writes: - -- `report.md` -- `report.json` - -into the output directory you provide. If you omit the output directory, the files are written into the current working directory. +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 +- selected `pam_sss(...:auth)` failure variants + +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 +- MFA abuse +- SSH key misuse +- Many PAM-specific failures beyond the parsed `pam_unix`, `pam_faillock`, and `pam_sss` sample patterns +- Cross-file or cross-host correlation -## Sample Output +## Build + +```bash +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +``` + +For fresh-machine setup and repeatable local presets, see [`docs/dev-setup.md`](./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 ./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 +``` + +The CLI writes: + +- `report.md` +- `report.json` + +into the output directory you provide. If you omit the output directory, the files are written into the current working directory. + +When you add `--csv`, LogLens also writes: + +- `findings.csv` +- `warnings.csv` + +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`, `line_number`, `message` + +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 For sanitized sample input, see [`assets/sample_auth.log`](./assets/sample_auth.log) and [`assets/sample_journalctl_short_full.log`](./assets/sample_journalctl_short_full.log). @@ -148,24 +180,33 @@ The config file schema is intentionally small and strict: "counts_as_attempt_evidence": true, "counts_as_terminal_auth_failure": true }, - "ssh_failed_publickey": { - "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 + "ssh_failed_publickey": { + "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 } } } ``` -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 @@ -192,16 +233,14 @@ Tue 2026-03-10 08:31:18 UTC example-host sshd[2245]: Connection closed by authen ## Known Limitations - `syslog_legacy` requires an explicit year; LogLens does not guess one implicitly. -- `journalctl_short_full` currently supports `UTC`, `GMT`, `Z`, and numeric timezone offsets, not arbitrary timezone abbreviations. -- Parser coverage is intentionally narrow and focused on common `sshd`, `sudo`, and `pam_unix` variants. -- Unsupported lines are surfaced as parser telemetry and warnings, not as detector findings. -- `pam_unix` auth failures remain lower-confidence by default unless signal mappings explicitly upgrade them. +- `journalctl_short_full` currently supports `UTC`, `GMT`, `Z`, and numeric timezone offsets, not arbitrary timezone abbreviations. +- Parser coverage is 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. - Findings are rule-based triage aids, not incident verdicts or attribution. -## Future Roadmap - -- Additional auth patterns and PAM coverage -- Better host-level summaries -- Optional CSV export -- Larger sanitized test corpus +## Future Roadmap + +- Additional auth patterns and PAM coverage +- Larger sanitized test corpus diff --git a/assets/parser_auth_families_journalctl_short_full.log b/assets/parser_auth_families_journalctl_short_full.log new file mode 100644 index 0000000..f823b9e --- /dev/null +++ b/assets/parser_auth_families_journalctl_short_full.log @@ -0,0 +1,13 @@ +Wed 2026-03-11 10:00:01 UTC example-host sshd[3100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY +Wed 2026-03-11 10:00:20 UTC example-host sshd[3101]: Accepted password for bob from 203.0.113.73 port 53001 ssh2 +Wed 2026-03-11 10:00:36 UTC example-host sshd[3102]: Failed publickey for invalid user svc-deploy from 203.0.113.74 port 53002 ssh2 +Wed 2026-03-11 10:00:42 UTC example-host pam_faillock(sshd:auth): Consecutive login failures for user alice account temporarily locked from 203.0.113.71 +Wed 2026-03-11 10:01:13 UTC example-host pam_faillock(sshd:auth): Authentication failure for user bob from 203.0.113.72 +Wed 2026-03-11 10:01:40 UTC example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.75 user=carol +Wed 2026-03-11 10:01:54 UTC example-host pam_faillock(sshd:auth): User carol successfully authenticated +Wed 2026-03-11 10:02:25 UTC example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure) +Wed 2026-03-11 10:02:44 UTC example-host pam_unix(sudo:session): session opened for user root by erin(uid=0) +Wed 2026-03-11 10:03:05 UTC example-host pam_faillock(sshd:auth): Account temporarily locked for user frank +Wed 2026-03-11 10:03:24 UTC example-host pam_sss(sshd:auth): received for user grace: 10 (User not known to the underlying authentication module) +Wed 2026-03-11 10:03:43 UTC example-host pam_sss(sshd:auth): received for user heidi: 9 (Authentication service cannot retrieve authentication info) +Wed 2026-03-11 10:04:02 UTC example-host pam_unix(sshd:session): session closed for user alice diff --git a/assets/parser_auth_families_syslog.log b/assets/parser_auth_families_syslog.log new file mode 100644 index 0000000..5475ffa --- /dev/null +++ b/assets/parser_auth_families_syslog.log @@ -0,0 +1,13 @@ +Mar 11 10:00:01 example-host sshd[2100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY +Mar 11 10:00:20 example-host sshd[2101]: Accepted password for bob from 203.0.113.73 port 53001 ssh2 +Mar 11 10:00:36 example-host sshd[2102]: Failed publickey for invalid user svc-deploy from 203.0.113.74 port 53002 ssh2 +Mar 11 10:00:42 example-host pam_faillock(sshd:auth): Consecutive login failures for user alice account temporarily locked from 203.0.113.71 +Mar 11 10:01:13 example-host pam_faillock(sshd:auth): Authentication failure for user bob from 203.0.113.72 +Mar 11 10:01:40 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.75 user=carol +Mar 11 10:01:54 example-host pam_faillock(sshd:auth): User carol successfully authenticated +Mar 11 10:02:25 example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure) +Mar 11 10:02:44 example-host pam_unix(sudo:session): session opened for user root by erin(uid=0) +Mar 11 10:03:05 example-host pam_faillock(sshd:auth): Account temporarily locked for user frank +Mar 11 10:03:24 example-host pam_sss(sshd:auth): received for user grace: 10 (User not known to the underlying authentication module) +Mar 11 10:03:43 example-host pam_sss(sshd:auth): received for user heidi: 9 (Authentication service cannot retrieve authentication info) +Mar 11 10:04:02 example-host pam_unix(sshd:session): session closed for user alice diff --git a/assets/parser_fixture_matrix_journalctl_short_full.log b/assets/parser_fixture_matrix_journalctl_short_full.log index 38486b4..a4e217a 100644 --- a/assets/parser_fixture_matrix_journalctl_short_full.log +++ b/assets/parser_fixture_matrix_journalctl_short_full.log @@ -4,9 +4,20 @@ Tue 2026-03-10 09:01:15 UTC example-host sshd[3002]: Invalid user backup from 20 Tue 2026-03-10 09:01:52 UTC example-host pam_unix(sshd:auth): authentication failure; user=alice euid=0 tty=ssh rhost=203.0.113.40 Tue 2026-03-10 09:02:30 UTC example-host pam_unix(sudo:session): session opened for user root(uid=0) by alice(uid=1000) Tue 2026-03-10 09:03:05 UTC example-host pam_unix(su-l:session): session opened for user root by bob(uid=1001) +Tue 2026-03-10 09:03: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] Tue 2026-03-10 09:05:02 UTC example-host sshd[3006]: Disconnected from authenticating user dave 203.0.113.53 port 52013 [preauth] Tue 2026-03-10 09:05:34 UTC example-host sshd[3007]: Timeout, client not responding from 203.0.113.54 port 52014 +Tue 2026-03-10 09:05:46 UTC example-host sshd[3010]: Received disconnect from 203.0.113.55 port 52015:11: disconnected by user +Tue 2026-03-10 09:05:58 UTC example-host sshd[3011]: Unable to negotiate with 203.0.113.56 port 52016: no matching host key type found. Their offer: ssh-rsa Tue 2026-03-10 09:06:10 UTC example-host pam_unix(sshd:session): session closed for user alice diff --git a/assets/parser_fixture_matrix_syslog.log b/assets/parser_fixture_matrix_syslog.log index fface11..06535a5 100644 --- a/assets/parser_fixture_matrix_syslog.log +++ b/assets/parser_fixture_matrix_syslog.log @@ -4,9 +4,20 @@ Mar 10 09:01:15 example-host sshd[2002]: Invalid user backup from 203.0.113.12 p Mar 10 09:01:52 example-host pam_unix(sshd:auth): authentication failure; user=alice euid=0 tty=ssh rhost=203.0.113.40 Mar 10 09:02:30 example-host pam_unix(sudo:session): session opened for user root(uid=0) by alice(uid=1000) Mar 10 09:03:05 example-host pam_unix(su-l:session): session opened for user root by bob(uid=1001) +Mar 10 09:03: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] Mar 10 09:05:02 example-host sshd[2006]: Disconnected from authenticating user dave 203.0.113.53 port 52013 [preauth] Mar 10 09:05:34 example-host sshd[2007]: Timeout, client not responding from 203.0.113.54 port 52014 +Mar 10 09:05:46 example-host sshd[2010]: Received disconnect from 203.0.113.55 port 52015:11: disconnected by user +Mar 10 09:05:58 example-host sshd[2011]: Unable to negotiate with 203.0.113.56 port 52016: no matching host key type found. Their offer: ssh-rsa Mar 10 09:06:10 example-host pam_unix(sshd:session): session closed for user alice 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/dev-setup.md b/docs/dev-setup.md new file mode 100644 index 0000000..dd39328 --- /dev/null +++ b/docs/dev-setup.md @@ -0,0 +1,67 @@ +# Developer Setup + +This note is for contributors working in a fresh local environment. It keeps the setup small and mirrors the repository's existing CMake and CTest flow. + +## Required Tools + +- CMake 3.21 or newer for the shared presets in `CMakePresets.json` +- CMake 3.20 or newer for the manual fallback flow +- A C++20 compiler + - Windows: Visual Studio 2022 or Build Tools 2022 with the MSVC v143 toolset + - Linux: `g++` 10+ or `clang++` 14+ plus `make` or `ninja` +- Git for normal contribution flow + +## Quick Start With Presets + +`CMakePresets.json` includes two repeatable local entry points: + +- `dev-debug`: default local iteration build with tests enabled +- `ci-release`: release build intended to mirror the GitHub Actions CI flags + +Local debug iteration: + +```bash +cmake --preset dev-debug +cmake --build --preset dev-debug +ctest --preset dev-debug +``` + +Local CI-style validation: + +```bash +cmake --preset ci-release +cmake --build --preset ci-release +ctest --preset ci-release +``` + +## Manual Fallback + +If you do not want to use presets, or if your local CMake is 3.20 but not 3.21+, the equivalent manual flow is: + +```bash +cmake -S . -B build -D CMAKE_BUILD_TYPE=Debug -D BUILD_TESTING=ON +cmake --build build +ctest --test-dir build --output-on-failure +``` + +## Windows Notes + +- Run from a Developer PowerShell for Visual Studio 2022, an x64 Native Tools prompt, or another shell where the MSVC toolchain is already available. +- If `cmake` is missing from `PATH`, install either the Visual Studio C++ workload with CMake support or a standalone Kitware CMake package, then reopen the shell. +- Visual Studio generators are multi-config, so the presets set `Debug` or `Release` again at build and test time for consistency. + +## Linux Notes + +- A small Ubuntu/Debian-style setup is usually enough: + +```bash +sudo apt install cmake g++ make +``` + +- If you prefer Clang, configure manually with `-D CMAKE_CXX_COMPILER=clang++` or create a local user preset. + +## Expected Local Outputs + +- Build directories under `build/dev-debug` or `build/ci-release` +- Test runs for `parser`, `detector`, and `cli` +- `compile_commands.json` in the debug build directory when the selected generator supports it 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-brief.md b/docs/reviewer-brief.md index 0f3bdd3..2c9edfb 100644 --- a/docs/reviewer-brief.md +++ b/docs/reviewer-brief.md @@ -8,6 +8,14 @@ Linux auth logs are noisy, format-sensitive, and easy to parse incorrectly. Revi `LogLens` is a C++20 offline CLI for Linux authentication evidence. It parses `auth.log` / `secure` style syslog input and `journalctl --output=short-full` style input, normalizes the evidence, applies small rule-based detections, and emits deterministic Markdown and JSON reports with parser coverage telemetry. +## Reviewer Evidence + +- Reproducible command: `./build/loglens --mode syslog --year 2026 ./assets/sample_auth.log ./out` +- Deterministic outputs: `report.md`, `report.json`, optional `findings.csv`, optional `warnings.csv`, and parser coverage telemetry. +- Tests / CI: CTest coverage plus GitHub Actions CI on Ubuntu and Windows; CodeQL is required on protected main. +- Release evidence: changelog, release process docs, versioned release notes, and GitHub release artifacts. +- Non-goals: live collection, SIEM replacement, cross-host correlation, exploitation, credential attack automation, or incident verdicts. + ## Quick run ```bash diff --git a/docs/reviewer-path.md b/docs/reviewer-path.md new file mode 100644 index 0000000..fa3c33f --- /dev/null +++ b/docs/reviewer-path.md @@ -0,0 +1,45 @@ +# Reviewer path + +LogLens is easiest to review when the first question is explicit. The project is not trying to be a SIEM or a broad Linux auth parser; it is a C++20 offline CLI that makes authentication parsing, detection boundaries, and report artifacts inspectable. + +The core review lens is: + +> Parser observability > silent detection claims. + +That means unsupported and malformed lines should be visible as parser telemetry or warnings instead of disappearing behind confident-looking findings. + +## First choose the review question + +| Review question | Start here | Good stopping point | +| --- | --- | --- | +| What is LogLens? | [`README.md`](../README.md) and [`docs/reviewer-brief.md`](./reviewer-brief.md) | Can state the defensive scope, CLI shape, and non-goals | +| What log formats are supported? | README Detections plus [`docs/parser-contract.md`](./parser-contract.md) | Can name `syslog_legacy` and `journalctl_short_full`, including the explicit year requirement for syslog | +| What artifacts does it produce? | README Run / Sample Output plus [`tests/test_report_contracts.cpp`](../tests/test_report_contracts.cpp) | Can inspect `report.md`, `report.json`, `findings.csv`, and `warnings.csv` | +| Can the parser behavior be trusted? | [`docs/parser-contract.md`](./parser-contract.md), [`tests/test_parser.cpp`](../tests/test_parser.cpp), [`assets/parser_fixture_matrix_syslog.log`](../assets/parser_fixture_matrix_syslog.log), and [`assets/parser_fixture_matrix_journalctl_short_full.log`](../assets/parser_fixture_matrix_journalctl_short_full.log) | Can see known auth lines become events and unknown lines become warnings / telemetry | +| Is it production-ready? | README Known Limitations, [`CHANGELOG.md`](../CHANGELOG.md), and [`docs/release-v0.1.0.md`](./release-v0.1.0.md) | Can state MVP boundaries and what should not be inferred from the findings | + +## Ten-minute review path + +1. Read the README through Detections and Known Limitations. +2. Skim the reviewer brief for problem, evidence, and boundaries. +3. Run the sample command from the README or reviewer brief. +4. Open `report.md` and `report.json` in the output directory. +5. Compare parser fixture inputs with `docs/parser-contract.md` and `tests/test_parser.cpp` to see recognized and unsupported line handling. + +## What to look for + +- Parser correctness is treated as a first-class part of the tool, not a hidden precondition. +- Reports include coverage metrics such as parsed, unparsed, warning, and unknown-pattern counts. +- Detection rules are small, centralized, configurable, and threshold-based. +- Unsupported lines remain reviewable through warnings instead of becoming silent misses. +- Public examples use sanitized hosts, users, and documentation IP ranges. + +## Good review outcome + +A reviewer should be able to say: + +- LogLens is an offline defensive log-analysis CLI for Linux authentication evidence. +- It supports syslog-style auth logs and `journalctl --output=short-full` style logs. +- It produces deterministic Markdown, JSON, and optional CSV artifacts. +- It makes parser coverage visible before asking the reviewer to trust detection claims. +- It is a focused MVP, not a production SIEM, host correlation engine, or incident verdict system. 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 7f8ad2f..2cf5aea 100644 --- a/src/event.hpp +++ b/src/event.hpp @@ -12,11 +12,18 @@ enum class EventType { Unknown, SshFailedPassword, SshAcceptedPassword, + SshAcceptedPublicKey, + SshAcceptedKeyboardInteractive, SshInvalidUser, SshFailedPublicKey, + SshFailedKeyboardInteractive, + SshMaxAuthTries, PamAuthFailure, SessionOpened, - SudoCommand + SudoCommand, + SudoAuthFailure, + SudoPolicyDenied, + SuAuthFailure }; struct Event { @@ -37,16 +44,30 @@ inline std::string to_string(EventType type) { return "ssh_failed_password"; case EventType::SshAcceptedPassword: return "ssh_accepted_password"; + case EventType::SshAcceptedPublicKey: + return "ssh_accepted_publickey"; + case EventType::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 d5502b9..fb6dfc5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,32 +4,70 @@ #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; + bool emit_csv = false; std::filesystem::path input_path; std::filesystem::path output_directory; }; -void print_usage() { - std::cerr << "Usage: loglens [--config ] [--mode ] [--year ] [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)); } @@ -46,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"); @@ -56,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"); @@ -71,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"); @@ -81,6 +167,22 @@ 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; + continue; + } + if (argument.starts_with('-')) { throw std::runtime_error("unknown option: " + std::string{argv[index]}); } @@ -130,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) @@ -153,15 +265,20 @@ int main(int argc, char* argv[]) { parsed.quality, parsed.events, findings, - parsed.warnings}; + parsed.warnings, + app_config.detector.auth_signal_mappings}; - loglens::write_reports(report_data, options.output_directory); + loglens::write_reports(report_data, options.output_directory, options.emit_csv); std::cout << "Parsed events: " << parsed.events.size() << '\n'; std::cout << "Findings: " << findings.size() << '\n'; std::cout << "Warnings: " << parsed.warnings.size() << '\n'; std::cout << "Markdown report: " << (options.output_directory / "report.md").string() << '\n'; std::cout << "JSON report: " << (options.output_directory / "report.json").string() << '\n'; + if (options.emit_csv) { + std::cout << "Findings CSV: " << (options.output_directory / "findings.csv").string() << '\n'; + std::cout << "Warnings CSV: " << (options.output_directory / "warnings.csv").string() << '\n'; + } } catch (const std::exception& error) { std::cerr << "LogLens failed: " << error.what() << '\n'; return 1; diff --git a/src/parser.cpp b/src/parser.cpp index 0440fad..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) { @@ -327,6 +327,42 @@ bool parse_ssh_accepted_message(std::string_view message, Event& event) { return true; } +bool parse_ssh_accepted_publickey_message(std::string_view message, Event& event) { + static constexpr std::string_view accepted_prefix = "Accepted publickey for "; + if (!message.starts_with(accepted_prefix)) { + return false; + } + + auto remaining = message.substr(accepted_prefix.size()); + const auto username = consume_token(remaining); + if (username.empty()) { + return false; + } + + event.username.assign(username); + event.source_ip = extract_token_after(message, " from "); + event.event_type = EventType::SshAcceptedPublicKey; + return true; +} + +bool parse_ssh_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)) { @@ -349,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)) { @@ -367,6 +451,25 @@ bool parse_ssh_invalid_user_message(std::string_view message, Event& event) { return true; } +bool parse_pam_named_user_failure_message(std::string_view message, + std::string_view prefix, + Event& event) { + if (!message.starts_with(prefix)) { + return false; + } + + auto remaining = message.substr(prefix.size()); + const auto username = consume_token(remaining); + if (username.empty()) { + return false; + } + + event.username.assign(username); + event.source_ip = extract_token_after(message, " from "); + event.event_type = EventType::PamAuthFailure; + return true; +} + bool parse_pam_auth_failure_message(std::string_view message, Event& event) { static constexpr std::string_view auth_failure_prefix = "authentication failure;"; if (!message.starts_with(auth_failure_prefix)) { @@ -379,6 +482,30 @@ bool parse_pam_auth_failure_message(std::string_view message, Event& event) { return true; } +bool parse_pam_sss_received_failure_message(std::string_view message, Event& event) { + static constexpr std::string_view received_prefix = "received for user "; + static constexpr std::string_view failure_marker = "(Authentication failure)"; + + if (!message.starts_with(received_prefix) || message.find(failure_marker) == std::string_view::npos) { + return false; + } + + auto remaining = message.substr(received_prefix.size()); + const auto separator = remaining.find(':'); + if (separator == std::string_view::npos) { + return false; + } + + const auto username = trim(remaining.substr(0, separator)); + if (username.empty()) { + return false; + } + + event.username.assign(username); + event.event_type = EventType::PamAuthFailure; + return true; +} + bool parse_session_opened_message(std::string_view message, Event& event) { static constexpr std::string_view session_prefix = "session opened for user "; if (!message.starts_with(session_prefix)) { @@ -419,10 +546,109 @@ 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; + } + + if (parse_pam_named_user_failure_message(message, "Authentication failure for user ", event)) { + return true; + } + + return false; +} + +std::string classify_unknown_pam_faillock_pattern(std::string_view message) { + if (message.starts_with("User ") && message.find("successfully authenticated") != std::string_view::npos) { + return "pam_faillock_authsucc"; + } + + return "pam_faillock_other"; +} + +std::string classify_unknown_pam_sss_pattern(std::string_view message) { + if (message.find("User not known to the underlying authentication module") != std::string_view::npos) { + return "pam_sss_unknown_user"; + } + + if (message.find("Authentication service cannot retrieve authentication info") != std::string_view::npos) { + return "pam_sss_authinfo_unavail"; + } + + return "pam_sss_other"; +} + std::string classify_unknown_auth_pattern(const Event& event) { const auto message = std::string_view{event.message}; if (event.program == "sshd") { @@ -444,10 +670,22 @@ std::string classify_unknown_auth_pattern(const Event& event) { return "pam_unix_other"; } + if (event.program.starts_with("pam_faillock(")) { + return classify_unknown_pam_faillock_pattern(message); + } + + if (event.program.starts_with("pam_sss(")) { + return classify_unknown_pam_sss_pattern(message); + } + if (event.program == "sudo") { return "sudo_other"; } + if (event.program == "su") { + return "su_other"; + } + return "program_" + sanitize_pattern_label(event.program); } @@ -460,9 +698,21 @@ bool classify_event(Event& event) { if (parse_ssh_accepted_message(message, event)) { return true; } + if (parse_ssh_accepted_publickey_message(message, event)) { + return true; + } + if (parse_ssh_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; } @@ -479,10 +729,28 @@ bool classify_event(Event& event) { return false; } + if (event.program.starts_with("pam_faillock(")) { + return parse_pam_faillock_message(message, event); + } + + if (event.program.starts_with("pam_sss(")) { + if (parse_pam_auth_failure_message(message, event)) { + return true; + } + if (parse_pam_sss_received_failure_message(message, event)) { + return true; + } + return false; + } + if (event.program == "sudo") { return parse_sudo_message(message, event); } + if (event.program == "su") { + return parse_su_message(message, event); + } + return false; } @@ -656,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; } @@ -705,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 0b0698e..8fc4f47 100644 --- a/src/report.cpp +++ b/src/report.cpp @@ -4,14 +4,38 @@ #include #include #include +#include #include +#include #include #include +#include +#include +#include #include namespace loglens { namespace { +struct HostSummary { + std::string hostname; + std::size_t parsed_event_count = 0; + std::size_t finding_count = 0; + std::size_t warning_count = 0; + 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()); @@ -34,14 +58,166 @@ 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; + } + } + + 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) { + 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(safe_value.size() + 2); + + if (needs_quotes) { + escaped.push_back('"'); + } + + for (const char character : safe_value) { + if (character == '"') { + escaped += "\"\""; + } else { + escaped.push_back(character); } } + if (needs_quotes) { + escaped.push_back('"'); + } + 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 remove_stale_report_output(const std::filesystem::path& path) { + std::error_code error; + std::filesystem::remove(path, error); + if (error) { + throw std::runtime_error( + "unable to remove stale report output: " + path.string() + ": " + error.message()); + } +} + +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) { @@ -71,6 +247,7 @@ std::vector> build_event_counts(const std::vec std::vector> counts = { {EventType::SshFailedPassword, 0}, {EventType::SshAcceptedPassword, 0}, + {EventType::SshAcceptedPublicKey, 0}, {EventType::SshInvalidUser, 0}, {EventType::SshFailedPublicKey, 0}, {EventType::PamAuthFailure, 0}, @@ -112,6 +289,17 @@ std::string usernames_note(const Finding& finding) { return note.str(); } +std::string usernames_csv_field(const Finding& finding) { + std::ostringstream usernames; + for (std::size_t index = 0; index < finding.usernames.size(); ++index) { + if (index != 0) { + usernames << ';'; + } + usernames << finding.usernames[index]; + } + return usernames.str(); +} + std::string format_parse_success_rate(double rate) { std::ostringstream output; output << std::fixed << std::setprecision(4) << rate; @@ -124,6 +312,194 @@ 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); + } + return value; +} + +std::string_view consume_token(std::string_view& input) { + input = trim_left(input); + if (input.empty()) { + return {}; + } + + const auto separator = input.find(' '); + if (separator == std::string_view::npos) { + const auto token = input; + input = {}; + return token; + } + + const auto token = input.substr(0, separator); + input.remove_prefix(separator + 1); + return token; +} + +std::optional extract_hostname_from_input_line(std::string_view line, InputMode input_mode) { + auto remaining = line; + switch (input_mode) { + case InputMode::SyslogLegacy: + if (consume_token(remaining).empty() + || consume_token(remaining).empty() + || consume_token(remaining).empty()) { + return std::nullopt; + } + break; + case InputMode::JournalctlShortFull: + if (consume_token(remaining).empty() + || consume_token(remaining).empty() + || consume_token(remaining).empty() + || consume_token(remaining).empty()) { + return std::nullopt; + } + break; + default: + return std::nullopt; + } + + const auto hostname = consume_token(remaining); + if (hostname.empty()) { + return std::nullopt; + } + + return std::string(hostname); +} + +std::unordered_map load_hostnames_by_line(const ReportData& data) { + std::unordered_map hostnames_by_line; + if (data.warnings.empty()) { + return hostnames_by_line; + } + + std::ifstream input(data.input_path); + if (!input) { + return hostnames_by_line; + } + + std::string line; + std::size_t line_number = 0; + while (std::getline(input, line)) { + ++line_number; + const auto hostname = extract_hostname_from_input_line(line, data.parse_metadata.input_mode); + if (hostname.has_value()) { + hostnames_by_line.emplace(line_number, *hostname); + } + } + + return hostnames_by_line; +} + +bool is_matching_finding_signal(const Finding& finding, const AuthSignal& signal) { + if (signal.timestamp < finding.first_seen || signal.timestamp > finding.last_seen) { + return false; + } + + switch (finding.type) { + case FindingType::BruteForce: + return signal.counts_as_terminal_auth_failure + && signal.source_ip == finding.subject; + case FindingType::MultiUserProbing: + if (!signal.counts_as_attempt_evidence || signal.source_ip != finding.subject) { + return false; + } + if (finding.usernames.empty()) { + return true; + } + return std::find( + finding.usernames.begin(), + finding.usernames.end(), + signal.username) + != finding.usernames.end(); + case FindingType::SudoBurst: + return signal.counts_as_sudo_burst_evidence + && signal.username == finding.subject; + default: + return false; + } +} + +std::vector build_host_summaries(const ReportData& data) { + std::unordered_map summaries_by_host; + + for (const auto& event : data.events) { + if (event.hostname.empty()) { + continue; + } + + auto& summary = summaries_by_host[event.hostname]; + summary.hostname = event.hostname; + ++summary.parsed_event_count; + } + + const auto hostnames_by_line = load_hostnames_by_line(data); + for (const auto& warning : data.warnings) { + const auto hostname_it = hostnames_by_line.find(warning.line_number); + if (hostname_it == hostnames_by_line.end() || hostname_it->second.empty()) { + continue; + } + + auto& summary = summaries_by_host[hostname_it->second]; + summary.hostname = hostname_it->second; + ++summary.warning_count; + } + + if (summaries_by_host.size() <= 1) { + return {}; + } + + std::unordered_map hostname_by_event_line; + hostname_by_event_line.reserve(data.events.size()); + std::unordered_map> events_by_host; + events_by_host.reserve(summaries_by_host.size()); + + for (const auto& event : data.events) { + hostname_by_event_line.emplace(event.line_number, event.hostname); + events_by_host[event.hostname].push_back(event); + } + + const auto signals = build_auth_signals(data.events, data.auth_signal_mappings); + for (const auto& finding : data.findings) { + std::unordered_set matching_hosts; + for (const auto& signal : signals) { + if (!is_matching_finding_signal(finding, signal)) { + continue; + } + + const auto hostname_it = hostname_by_event_line.find(signal.line_number); + if (hostname_it == hostname_by_event_line.end() || hostname_it->second.empty()) { + continue; + } + matching_hosts.insert(hostname_it->second); + } + + for (const auto& hostname : matching_hosts) { + ++summaries_by_host[hostname].finding_count; + } + } + + std::vector summaries; + summaries.reserve(summaries_by_host.size()); + for (auto& [hostname, summary] : summaries_by_host) { + const auto events_it = events_by_host.find(hostname); + if (events_it != events_by_host.end()) { + summary.event_counts = build_event_counts(events_it->second); + } + summaries.push_back(std::move(summary)); + } + + std::sort(summaries.begin(), summaries.end(), [](const HostSummary& left, const HostSummary& right) { + return left.hostname < right.hostname; + }); + + return summaries; +} + } // namespace std::string render_markdown_report(const ReportData& data) { @@ -131,6 +507,7 @@ std::string render_markdown_report(const ReportData& data) { const auto findings = sorted_findings(data.findings); const auto warnings = sorted_warnings(data.warnings); const auto event_counts = build_event_counts(data.events); + const auto host_summaries = build_host_summaries(data); output << "# LogLens Report\n\n"; output << "## Summary\n\n"; @@ -140,7 +517,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'; @@ -148,6 +527,19 @@ std::string render_markdown_report(const ReportData& data) { output << "- Findings: " << findings.size() << '\n'; output << "- Parser warnings: " << warnings.size() << "\n\n"; + if (!host_summaries.empty()) { + output << "## Host Summary\n\n"; + output << "| Host | Parsed Events | Findings | Warnings |\n"; + output << "| --- | ---: | ---: | ---: |\n"; + for (const auto& summary : host_summaries) { + output << "| " << escape_markdown_table_cell(summary.hostname) + << " | " << summary.parsed_event_count + << " | " << summary.finding_count + << " | " << summary.warning_count << " |\n"; + } + output << '\n'; + } + output << "## Findings\n\n"; if (findings.empty()) { output << "No configured detections matched the analyzed events.\n\n"; @@ -155,12 +547,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'; } @@ -169,7 +561,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'; @@ -180,7 +572,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'; } @@ -192,7 +584,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"; } } @@ -204,6 +597,7 @@ std::string render_json_report(const ReportData& data) { const auto findings = sorted_findings(data.findings); const auto warnings = sorted_warnings(data.warnings); const auto event_counts = build_event_counts(data.events); + const auto host_summaries = build_host_summaries(data); output << "{\n"; output << " \"tool\": \"LogLens\",\n"; @@ -214,7 +608,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"; @@ -235,7 +631,31 @@ std::string render_json_report(const ReportData& data) { output << " {\"event_type\": \"" << to_string(type) << "\", \"count\": " << count << "}"; output << (index + 1 == event_counts.size() ? "\n" : ",\n"); } - output << " ],\n"; + output << " ]"; + if (!host_summaries.empty()) { + output << ",\n"; + output << " \"host_summaries\": [\n"; + for (std::size_t host_index = 0; host_index < host_summaries.size(); ++host_index) { + const auto& summary = host_summaries[host_index]; + output << " {\n"; + output << " \"hostname\": \"" << escape_json(summary.hostname) << "\",\n"; + output << " \"parsed_event_count\": " << summary.parsed_event_count << ",\n"; + output << " \"finding_count\": " << summary.finding_count << ",\n"; + output << " \"warning_count\": " << summary.warning_count << ",\n"; + output << " \"event_counts\": [\n"; + for (std::size_t event_index = 0; event_index < summary.event_counts.size(); ++event_index) { + const auto& [type, count] = summary.event_counts[event_index]; + output << " {\"event_type\": \"" << to_string(type) << "\", \"count\": " << count << "}"; + output << (event_index + 1 == summary.event_counts.size() ? "\n" : ",\n"); + } + output << " ]\n"; + output << " }"; + output << (host_index + 1 == host_summaries.size() ? "\n" : ",\n"); + } + output << " ],\n"; + } else { + output << ",\n"; + } output << " \"findings\": [\n"; for (std::size_t index = 0; index < findings.size(); ++index) { const auto& finding = findings[index]; @@ -271,14 +691,54 @@ std::string render_json_report(const ReportData& data) { return output.str(); } -void write_reports(const ReportData& data, const std::filesystem::path& output_directory) { - std::filesystem::create_directories(output_directory); +std::string render_findings_csv(const ReportData& data) { + std::ostringstream output; + const auto findings = sorted_findings(data.findings); + + output << "rule,subject_kind,subject,event_count,window_start,window_end,usernames,summary\n"; + for (const auto& finding : findings) { + output << escape_csv(to_string(finding.type)) << ',' + << escape_csv(finding.subject_kind) << ',' + << escape_csv(finding.subject) << ',' + << finding.event_count << ',' + << escape_csv(format_timestamp(finding.first_seen)) << ',' + << escape_csv(format_timestamp(finding.last_seen)) << ',' + << escape_csv(usernames_csv_field(finding)) << ',' + << escape_csv(finding.summary) << '\n'; + } + + return output.str(); +} + +std::string render_warnings_csv(const ReportData& data) { + std::ostringstream output; + const auto warnings = sorted_warnings(data.warnings); + + output << "kind,line_number,message\n"; + for (const auto& warning : warnings) { + output << "parse_warning," + << warning.line_number << ',' + << escape_csv(warning.reason) << '\n'; + } + + return output.str(); +} + +void write_reports(const ReportData& data, const std::filesystem::path& output_directory, bool emit_csv) { + ensure_output_directory(output_directory); - std::ofstream markdown_output(output_directory / "report.md"); - markdown_output << render_markdown_report(data); + write_text_file(output_directory / "report.md", render_markdown_report(data)); + write_text_file(output_directory / "report.json", render_json_report(data)); - std::ofstream json_output(output_directory / "report.json"); - json_output << 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) { + remove_stale_report_output(findings_csv_path); + remove_stale_report_output(warnings_csv_path); + return; + } + 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/report.hpp b/src/report.hpp index 3517ebc..1f22337 100644 --- a/src/report.hpp +++ b/src/report.hpp @@ -1,5 +1,6 @@ #pragma once +#include "signal.hpp" #include "detector.hpp" #include "parser.hpp" @@ -16,10 +17,13 @@ struct ReportData { std::vector events; std::vector findings; std::vector warnings; + AuthSignalConfig auth_signal_mappings; }; std::string render_markdown_report(const ReportData& data); std::string render_json_report(const ReportData& data); -void write_reports(const ReportData& data, const std::filesystem::path& output_directory); +std::string render_findings_csv(const ReportData& data); +std::string render_warnings_csv(const ReportData& data); +void write_reports(const ReportData& data, const std::filesystem::path& output_directory, bool emit_csv = false); } // namespace loglens diff --git a/src/signal.cpp b/src/signal.cpp index b07cffa..7b027a2 100644 --- a/src/signal.cpp +++ b/src/signal.cpp @@ -32,6 +32,18 @@ std::optional signal_mapping_for_event(const Event& event, const 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, @@ -55,6 +67,11 @@ std::optional signal_mapping_for_event(const Event& event, const 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; } 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/journalctl_short_full/input.log b/tests/fixtures/report_contracts/journalctl_short_full/input.log new file mode 100644 index 0000000..6927cef --- /dev/null +++ b/tests/fixtures/report_contracts/journalctl_short_full/input.log @@ -0,0 +1,16 @@ +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 +Tue 2026-03-10 08:12:05 UTC example-host sshd[2235]: Failed password for root from 203.0.113.10 port 51030 ssh2 +Tue 2026-03-10 08:13:10 UTC example-host sshd[2236]: Failed password for test from 203.0.113.10 port 51040 ssh +Tue 2026-03-10 08:14:44 UTC example-host sshd[2237]: Failed password for guest from 203.0.113.10 port 51050 ssh2 +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 +Tue 2026-03-10 08:20:10 UTC example-host sshd[2240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2 +Tue 2026-03-10 08:21:00 UTC example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh +Tue 2026-03-10 08:22:10 UTC example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe +Tue 2026-03-10 08:24:15 UTC example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/vi /etc/ssh/sshd_config +Tue 2026-03-10 08:25:30 UTC example-host sshd[2241]: Failed password for bob from 203.0.113.30 port 51234 ssh2 +Tue 2026-03-10 08:26:02 UTC example-host sshd[2242]: Invalid user backup from 203.0.113.31 port 51236 +Tue 2026-03-10 08:28:33 UTC example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.41 user=alice +Tue 2026-03-10 08:29:50 UTC example-host pam_unix(sudo:session): session opened for user root by alice(uid=0) +Tue 2026-03-10 08:30:12 UTC example-host sshd[2244]: Failed password for invalid user qauser from 203.0.113.50 port 51290 ssh2 +Tue 2026-03-10 08:31:18 UTC example-host sshd[2245]: Connection closed by authenticating user alice 203.0.113.51 port 51291 [preauth] +Tue 2026-03-10 08:32:26 UTC example-host sshd[2246]: Timeout, client not responding from 203.0.113.52 port 51292 diff --git a/tests/fixtures/report_contracts/journalctl_short_full/report.json b/tests/fixtures/report_contracts/journalctl_short_full/report.json new file mode 100644 index 0000000..abffa2e --- /dev/null +++ b/tests/fixtures/report_contracts/journalctl_short_full/report.json @@ -0,0 +1,64 @@ +{ + "tool": "LogLens", + "input": "tests/fixtures/report_contracts/journalctl_short_full/input.log", + "input_mode": "journalctl_short_full", + "timezone_present": true, + "parser_quality": { + "total_lines": 16, + "parsed_lines": 14, + "unparsed_lines": 2, + "parse_success_rate": 0.8750, + "top_unknown_patterns": [ + {"pattern": "sshd_connection_closed_preauth", "count": 1}, + {"pattern": "sshd_timeout_or_disconnection", "count": 1} + ] + }, + "parsed_event_count": 14, + "warning_count": 2, + "finding_count": 3, + "event_counts": [ + {"event_type": "ssh_failed_password", "count": 4}, + {"event_type": "ssh_accepted_password", "count": 1}, + {"event_type": "ssh_invalid_user", "count": 3}, + {"event_type": "ssh_failed_publickey", "count": 1}, + {"event_type": "pam_auth_failure", "count": 1}, + {"event_type": "session_opened", "count": 1}, + {"event_type": "sudo_command", "count": 3} + ], + "findings": [ + { + "rule": "brute_force", + "subject_kind": "source_ip", + "subject": "203.0.113.10", + "event_count": 5, + "window_start": "2026-03-10 08:11:22", + "window_end": "2026-03-10 08:18:05", + "usernames": [], + "summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes." + }, + { + "rule": "multi_user_probing", + "subject_kind": "source_ip", + "subject": "203.0.113.10", + "event_count": 5, + "window_start": "2026-03-10 08:11:22", + "window_end": "2026-03-10 08:18:05", + "usernames": ["admin", "deploy", "guest", "root", "test"], + "summary": "203.0.113.10 targeted 5 usernames within 15 minutes." + }, + { + "rule": "sudo_burst", + "subject_kind": "username", + "subject": "alice", + "event_count": 3, + "window_start": "2026-03-10 08:21:00", + "window_end": "2026-03-10 08:24:15", + "usernames": [], + "summary": "alice ran 3 sudo commands within 5 minutes." + } + ], + "warnings": [ + {"line_number": 15, "reason": "unrecognized auth pattern: sshd_connection_closed_preauth"}, + {"line_number": 16, "reason": "unrecognized auth pattern: sshd_timeout_or_disconnection"} + ] +} diff --git a/tests/fixtures/report_contracts/journalctl_short_full/report.md b/tests/fixtures/report_contracts/journalctl_short_full/report.md new file mode 100644 index 0000000..a122bd9 --- /dev/null +++ b/tests/fixtures/report_contracts/journalctl_short_full/report.md @@ -0,0 +1,48 @@ +# LogLens Report + +## Summary + +- Input: `tests/fixtures/report_contracts/journalctl_short_full/input.log` +- Input mode: journalctl_short_full +- Timezone present: true +- Total lines: 16 +- Parsed lines: 14 +- Unparsed lines: 2 +- Parse success rate: 87.50% +- Parsed events: 14 +- Findings: 3 +- Parser warnings: 2 + +## Findings + +| Rule | Subject | Count | Window | Notes | +| --- | --- | ---: | --- | --- | +| brute_force | 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. | +| multi_user_probing | 203.0.113.10 | 5 | 2026-03-10 08:11:22 -> 2026-03-10 08:18:05 | 203.0.113.10 targeted 5 usernames within 15 minutes. Usernames: admin, deploy, guest, root, test | +| sudo_burst | alice | 3 | 2026-03-10 08:21:00 -> 2026-03-10 08:24:15 | alice ran 3 sudo commands within 5 minutes. | + +## Event Counts + +| Event Type | Count | +| --- | ---: | +| ssh_failed_password | 4 | +| ssh_accepted_password | 1 | +| ssh_invalid_user | 3 | +| ssh_failed_publickey | 1 | +| pam_auth_failure | 1 | +| session_opened | 1 | +| sudo_command | 3 | + +## Parser Quality + +| Unknown Pattern | Count | +| --- | ---: | +| sshd_connection_closed_preauth | 1 | +| sshd_timeout_or_disconnection | 1 | + +## Parser Warnings + +| Line | Reason | +| ---: | --- | +| 15 | unrecognized auth pattern: sshd_connection_closed_preauth | +| 16 | unrecognized auth pattern: sshd_timeout_or_disconnection | diff --git a/tests/fixtures/report_contracts/multi_host_journalctl_short_full/input.log b/tests/fixtures/report_contracts/multi_host_journalctl_short_full/input.log new file mode 100644 index 0000000..d549760 --- /dev/null +++ b/tests/fixtures/report_contracts/multi_host_journalctl_short_full/input.log @@ -0,0 +1,15 @@ +Wed 2026-03-11 09:00:00 UTC alpha-host sshd[2301]: Failed password for invalid user admin from 203.0.113.10 port 52022 ssh2 +Wed 2026-03-11 09:01:05 UTC alpha-host sshd[2302]: Failed password for root from 203.0.113.10 port 52030 ssh2 +Wed 2026-03-11 09:02:10 UTC alpha-host sshd[2303]: Failed password for test from 203.0.113.10 port 52040 ssh2 +Wed 2026-03-11 09:03:44 UTC alpha-host sshd[2304]: Failed password for guest from 203.0.113.10 port 52050 ssh2 +Wed 2026-03-11 09:04:05 UTC alpha-host sshd[2305]: Failed password for invalid user deploy from 203.0.113.10 port 52060 ssh2 +Wed 2026-03-11 09:05:20 UTC alpha-host sshd[2306]: Accepted password for ops from 203.0.113.60 port 52070 ssh2 +Wed 2026-03-11 09:06:02 UTC alpha-host pam_faillock(sshd:auth): Authentication failure for user svc-ci from 203.0.113.61 +Wed 2026-03-11 09:10:10 UTC beta-host sshd[2401]: Accepted publickey for alice from 203.0.113.20 port 52111 ssh2 +Wed 2026-03-11 09:11:00 UTC beta-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh +Wed 2026-03-11 09:12:10 UTC beta-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe +Wed 2026-03-11 09:13:02 UTC beta-host pam_sss(sshd:auth): received for user mallory: 7 (Authentication failure) +Wed 2026-03-11 09:13:38 UTC beta-host pam_sss(sshd:auth): received for user ghost: 10 (User not known to the underlying authentication module) +Wed 2026-03-11 09:14:15 UTC beta-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/vi /etc/ssh/sshd_config +Wed 2026-03-11 09:15:12 UTC alpha-host sshd[2307]: Connection closed by authenticating user alice 203.0.113.50 port 52290 [preauth] +Wed 2026-03-11 09:16:18 UTC beta-host sshd[2402]: Timeout, client not responding from 203.0.113.51 port 52291 diff --git a/tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.json b/tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.json new file mode 100644 index 0000000..a71b07d --- /dev/null +++ b/tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.json @@ -0,0 +1,90 @@ +{ + "tool": "LogLens", + "input": "tests/fixtures/report_contracts/multi_host_journalctl_short_full/input.log", + "input_mode": "journalctl_short_full", + "timezone_present": true, + "parser_quality": { + "total_lines": 15, + "parsed_lines": 12, + "unparsed_lines": 3, + "parse_success_rate": 0.8000, + "top_unknown_patterns": [ + {"pattern": "pam_sss_unknown_user", "count": 1}, + {"pattern": "sshd_connection_closed_preauth", "count": 1}, + {"pattern": "sshd_timeout_or_disconnection", "count": 1} + ] + }, + "parsed_event_count": 12, + "warning_count": 3, + "finding_count": 3, + "event_counts": [ + {"event_type": "ssh_failed_password", "count": 3}, + {"event_type": "ssh_accepted_password", "count": 1}, + {"event_type": "ssh_accepted_publickey", "count": 1}, + {"event_type": "ssh_invalid_user", "count": 2}, + {"event_type": "pam_auth_failure", "count": 2}, + {"event_type": "sudo_command", "count": 3} + ], + "host_summaries": [ + { + "hostname": "alpha-host", + "parsed_event_count": 7, + "finding_count": 2, + "warning_count": 1, + "event_counts": [ + {"event_type": "ssh_failed_password", "count": 3}, + {"event_type": "ssh_accepted_password", "count": 1}, + {"event_type": "ssh_invalid_user", "count": 2}, + {"event_type": "pam_auth_failure", "count": 1} + ] + }, + { + "hostname": "beta-host", + "parsed_event_count": 5, + "finding_count": 1, + "warning_count": 2, + "event_counts": [ + {"event_type": "ssh_accepted_publickey", "count": 1}, + {"event_type": "pam_auth_failure", "count": 1}, + {"event_type": "sudo_command", "count": 3} + ] + } + ], + "findings": [ + { + "rule": "brute_force", + "subject_kind": "source_ip", + "subject": "203.0.113.10", + "event_count": 5, + "window_start": "2026-03-11 09:00:00", + "window_end": "2026-03-11 09:04:05", + "usernames": [], + "summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes." + }, + { + "rule": "multi_user_probing", + "subject_kind": "source_ip", + "subject": "203.0.113.10", + "event_count": 5, + "window_start": "2026-03-11 09:00:00", + "window_end": "2026-03-11 09:04:05", + "usernames": ["admin", "deploy", "guest", "root", "test"], + "summary": "203.0.113.10 targeted 5 usernames within 15 minutes." + }, + { + "rule": "sudo_burst", + "subject_kind": "username", + "subject": "alice", + "event_count": 3, + "window_start": "2026-03-11 09:11:00", + "window_end": "2026-03-11 09:14:15", + "usernames": [], + "summary": "alice ran 3 sudo commands within 5 minutes." + } + ], + "warnings": [ + {"line_number": 12, "reason": "unrecognized auth pattern: pam_sss_unknown_user"}, + {"line_number": 14, "reason": "unrecognized auth pattern: sshd_connection_closed_preauth"}, + {"line_number": 15, "reason": "unrecognized auth pattern: sshd_timeout_or_disconnection"} + ] +} diff --git a/tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.md b/tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.md new file mode 100644 index 0000000..b7a4d93 --- /dev/null +++ b/tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.md @@ -0,0 +1,56 @@ +# LogLens Report + +## Summary + +- Input: `tests/fixtures/report_contracts/multi_host_journalctl_short_full/input.log` +- Input mode: journalctl_short_full +- Timezone present: true +- Total lines: 15 +- Parsed lines: 12 +- Unparsed lines: 3 +- Parse success rate: 80.00% +- Parsed events: 12 +- Findings: 3 +- Parser warnings: 3 + +## Host Summary + +| Host | Parsed Events | Findings | Warnings | +| --- | ---: | ---: | ---: | +| alpha-host | 7 | 2 | 1 | +| beta-host | 5 | 1 | 2 | + +## Findings + +| Rule | Subject | Count | Window | Notes | +| --- | --- | ---: | --- | --- | +| brute_force | 203.0.113.10 | 5 | 2026-03-11 09:00:00 -> 2026-03-11 09:04:05 | 5 failed SSH attempts from 203.0.113.10 within 10 minutes. | +| multi_user_probing | 203.0.113.10 | 5 | 2026-03-11 09:00:00 -> 2026-03-11 09:04:05 | 203.0.113.10 targeted 5 usernames within 15 minutes. Usernames: admin, deploy, guest, root, test | +| sudo_burst | alice | 3 | 2026-03-11 09:11:00 -> 2026-03-11 09:14:15 | alice ran 3 sudo commands within 5 minutes. | + +## Event Counts + +| Event Type | Count | +| --- | ---: | +| ssh_failed_password | 3 | +| ssh_accepted_password | 1 | +| ssh_accepted_publickey | 1 | +| ssh_invalid_user | 2 | +| pam_auth_failure | 2 | +| sudo_command | 3 | + +## Parser Quality + +| Unknown Pattern | Count | +| --- | ---: | +| pam_sss_unknown_user | 1 | +| sshd_connection_closed_preauth | 1 | +| sshd_timeout_or_disconnection | 1 | + +## Parser Warnings + +| Line | Reason | +| ---: | --- | +| 12 | unrecognized auth pattern: pam_sss_unknown_user | +| 14 | unrecognized auth pattern: sshd_connection_closed_preauth | +| 15 | unrecognized auth pattern: sshd_timeout_or_disconnection | diff --git a/tests/fixtures/report_contracts/multi_host_syslog_legacy/findings.csv b/tests/fixtures/report_contracts/multi_host_syslog_legacy/findings.csv new file mode 100644 index 0000000..2836703 --- /dev/null +++ b/tests/fixtures/report_contracts/multi_host_syslog_legacy/findings.csv @@ -0,0 +1,4 @@ +rule,subject_kind,subject,event_count,window_start,window_end,usernames,summary +brute_force,source_ip,203.0.113.10,5,2026-03-11 09:00:00,2026-03-11 09:04:05,,5 failed SSH attempts from 203.0.113.10 within 10 minutes. +multi_user_probing,source_ip,203.0.113.10,5,2026-03-11 09:00:00,2026-03-11 09:04:05,admin;deploy;guest;root;test,203.0.113.10 targeted 5 usernames within 15 minutes. +sudo_burst,username,alice,3,2026-03-11 09:11:00,2026-03-11 09:14:15,,alice ran 3 sudo commands within 5 minutes. diff --git a/tests/fixtures/report_contracts/multi_host_syslog_legacy/input.log b/tests/fixtures/report_contracts/multi_host_syslog_legacy/input.log new file mode 100644 index 0000000..9ac9cca --- /dev/null +++ b/tests/fixtures/report_contracts/multi_host_syslog_legacy/input.log @@ -0,0 +1,15 @@ +Mar 11 09:00:00 alpha-host sshd[1301]: Failed password for invalid user admin from 203.0.113.10 port 52022 ssh2 +Mar 11 09:01:05 alpha-host sshd[1302]: Failed password for root from 203.0.113.10 port 52030 ssh2 +Mar 11 09:02:10 alpha-host sshd[1303]: Failed password for test from 203.0.113.10 port 52040 ssh2 +Mar 11 09:03:44 alpha-host sshd[1304]: Failed password for guest from 203.0.113.10 port 52050 ssh2 +Mar 11 09:04:05 alpha-host sshd[1305]: Failed password for invalid user deploy from 203.0.113.10 port 52060 ssh2 +Mar 11 09:05:20 alpha-host sshd[1306]: Accepted password for ops from 203.0.113.60 port 52070 ssh2 +Mar 11 09:06:02 alpha-host pam_faillock(sshd:auth): Authentication failure for user svc-ci from 203.0.113.61 +Mar 11 09:10:10 beta-host sshd[1401]: Accepted publickey for alice from 203.0.113.20 port 52111 ssh2 +Mar 11 09:11:00 beta-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh +Mar 11 09:12:10 beta-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe +Mar 11 09:13:02 beta-host pam_sss(sshd:auth): received for user mallory: 7 (Authentication failure) +Mar 11 09:13:38 beta-host pam_sss(sshd:auth): received for user ghost: 10 (User not known to the underlying authentication module) +Mar 11 09:14:15 beta-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/vi /etc/ssh/sshd_config +Mar 11 09:15:12 alpha-host sshd[1307]: Connection closed by authenticating user alice 203.0.113.50 port 52290 [preauth] +Mar 11 09:16:18 beta-host sshd[1402]: Timeout, client not responding from 203.0.113.51 port 52291 diff --git a/tests/fixtures/report_contracts/multi_host_syslog_legacy/report.json b/tests/fixtures/report_contracts/multi_host_syslog_legacy/report.json new file mode 100644 index 0000000..c8d2ec6 --- /dev/null +++ b/tests/fixtures/report_contracts/multi_host_syslog_legacy/report.json @@ -0,0 +1,91 @@ +{ + "tool": "LogLens", + "input": "tests/fixtures/report_contracts/multi_host_syslog_legacy/input.log", + "input_mode": "syslog_legacy", + "assume_year": 2026, + "timezone_present": false, + "parser_quality": { + "total_lines": 15, + "parsed_lines": 12, + "unparsed_lines": 3, + "parse_success_rate": 0.8000, + "top_unknown_patterns": [ + {"pattern": "pam_sss_unknown_user", "count": 1}, + {"pattern": "sshd_connection_closed_preauth", "count": 1}, + {"pattern": "sshd_timeout_or_disconnection", "count": 1} + ] + }, + "parsed_event_count": 12, + "warning_count": 3, + "finding_count": 3, + "event_counts": [ + {"event_type": "ssh_failed_password", "count": 3}, + {"event_type": "ssh_accepted_password", "count": 1}, + {"event_type": "ssh_accepted_publickey", "count": 1}, + {"event_type": "ssh_invalid_user", "count": 2}, + {"event_type": "pam_auth_failure", "count": 2}, + {"event_type": "sudo_command", "count": 3} + ], + "host_summaries": [ + { + "hostname": "alpha-host", + "parsed_event_count": 7, + "finding_count": 2, + "warning_count": 1, + "event_counts": [ + {"event_type": "ssh_failed_password", "count": 3}, + {"event_type": "ssh_accepted_password", "count": 1}, + {"event_type": "ssh_invalid_user", "count": 2}, + {"event_type": "pam_auth_failure", "count": 1} + ] + }, + { + "hostname": "beta-host", + "parsed_event_count": 5, + "finding_count": 1, + "warning_count": 2, + "event_counts": [ + {"event_type": "ssh_accepted_publickey", "count": 1}, + {"event_type": "pam_auth_failure", "count": 1}, + {"event_type": "sudo_command", "count": 3} + ] + } + ], + "findings": [ + { + "rule": "brute_force", + "subject_kind": "source_ip", + "subject": "203.0.113.10", + "event_count": 5, + "window_start": "2026-03-11 09:00:00", + "window_end": "2026-03-11 09:04:05", + "usernames": [], + "summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes." + }, + { + "rule": "multi_user_probing", + "subject_kind": "source_ip", + "subject": "203.0.113.10", + "event_count": 5, + "window_start": "2026-03-11 09:00:00", + "window_end": "2026-03-11 09:04:05", + "usernames": ["admin", "deploy", "guest", "root", "test"], + "summary": "203.0.113.10 targeted 5 usernames within 15 minutes." + }, + { + "rule": "sudo_burst", + "subject_kind": "username", + "subject": "alice", + "event_count": 3, + "window_start": "2026-03-11 09:11:00", + "window_end": "2026-03-11 09:14:15", + "usernames": [], + "summary": "alice ran 3 sudo commands within 5 minutes." + } + ], + "warnings": [ + {"line_number": 12, "reason": "unrecognized auth pattern: pam_sss_unknown_user"}, + {"line_number": 14, "reason": "unrecognized auth pattern: sshd_connection_closed_preauth"}, + {"line_number": 15, "reason": "unrecognized auth pattern: sshd_timeout_or_disconnection"} + ] +} diff --git a/tests/fixtures/report_contracts/multi_host_syslog_legacy/report.md b/tests/fixtures/report_contracts/multi_host_syslog_legacy/report.md new file mode 100644 index 0000000..7959f2b --- /dev/null +++ b/tests/fixtures/report_contracts/multi_host_syslog_legacy/report.md @@ -0,0 +1,57 @@ +# LogLens Report + +## Summary + +- Input: `tests/fixtures/report_contracts/multi_host_syslog_legacy/input.log` +- Input mode: syslog_legacy +- Assume year: 2026 +- Timezone present: false +- Total lines: 15 +- Parsed lines: 12 +- Unparsed lines: 3 +- Parse success rate: 80.00% +- Parsed events: 12 +- Findings: 3 +- Parser warnings: 3 + +## Host Summary + +| Host | Parsed Events | Findings | Warnings | +| --- | ---: | ---: | ---: | +| alpha-host | 7 | 2 | 1 | +| beta-host | 5 | 1 | 2 | + +## Findings + +| Rule | Subject | Count | Window | Notes | +| --- | --- | ---: | --- | --- | +| brute_force | 203.0.113.10 | 5 | 2026-03-11 09:00:00 -> 2026-03-11 09:04:05 | 5 failed SSH attempts from 203.0.113.10 within 10 minutes. | +| multi_user_probing | 203.0.113.10 | 5 | 2026-03-11 09:00:00 -> 2026-03-11 09:04:05 | 203.0.113.10 targeted 5 usernames within 15 minutes. Usernames: admin, deploy, guest, root, test | +| sudo_burst | alice | 3 | 2026-03-11 09:11:00 -> 2026-03-11 09:14:15 | alice ran 3 sudo commands within 5 minutes. | + +## Event Counts + +| Event Type | Count | +| --- | ---: | +| ssh_failed_password | 3 | +| ssh_accepted_password | 1 | +| ssh_accepted_publickey | 1 | +| ssh_invalid_user | 2 | +| pam_auth_failure | 2 | +| sudo_command | 3 | + +## Parser Quality + +| Unknown Pattern | Count | +| --- | ---: | +| pam_sss_unknown_user | 1 | +| sshd_connection_closed_preauth | 1 | +| sshd_timeout_or_disconnection | 1 | + +## Parser Warnings + +| Line | Reason | +| ---: | --- | +| 12 | unrecognized auth pattern: pam_sss_unknown_user | +| 14 | unrecognized auth pattern: sshd_connection_closed_preauth | +| 15 | unrecognized auth pattern: sshd_timeout_or_disconnection | diff --git a/tests/fixtures/report_contracts/multi_host_syslog_legacy/warnings.csv b/tests/fixtures/report_contracts/multi_host_syslog_legacy/warnings.csv new file mode 100644 index 0000000..14779cf --- /dev/null +++ b/tests/fixtures/report_contracts/multi_host_syslog_legacy/warnings.csv @@ -0,0 +1,4 @@ +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/findings.csv b/tests/fixtures/report_contracts/syslog_legacy/findings.csv new file mode 100644 index 0000000..51b0a0f --- /dev/null +++ b/tests/fixtures/report_contracts/syslog_legacy/findings.csv @@ -0,0 +1,4 @@ +rule,subject_kind,subject,event_count,window_start,window_end,usernames,summary +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. +multi_user_probing,source_ip,203.0.113.10,5,2026-03-10 08:11:22,2026-03-10 08:18:05,admin;deploy;guest;root;test,203.0.113.10 targeted 5 usernames within 15 minutes. +sudo_burst,username,alice,3,2026-03-10 08:21:00,2026-03-10 08:24:15,,alice ran 3 sudo commands within 5 minutes. diff --git a/tests/fixtures/report_contracts/syslog_legacy/input.log b/tests/fixtures/report_contracts/syslog_legacy/input.log new file mode 100644 index 0000000..57763f5 --- /dev/null +++ b/tests/fixtures/report_contracts/syslog_legacy/input.log @@ -0,0 +1,16 @@ +Mar 10 08:11:22 example-host sshd[1234]: Failed password for invalid user admin from 203.0.113.10 port 51022 ssh2 +Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2 +Mar 10 08:13:10 example-host sshd[1236]: Failed password for test from 203.0.113.10 port 51040 ssh2 +Mar 10 08:14:44 example-host sshd[1237]: Failed password for guest from 203.0.113.10 port 51050 ssh2 +Mar 10 08:18:05 example-host sshd[1238]: Failed password for invalid user deploy from 203.0.113.10 port 51060 ssh2 +Mar 10 08:20:10 example-host sshd[1240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2 +Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh +Mar 10 08:22:10 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe +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 +Mar 10 08:25:30 example-host sshd[1241]: Failed password for bob from 203.0.113.30 port 51234 ssh2 +Mar 10 08:26:02 example-host sshd[1242]: Invalid user backup from 203.0.113.31 port 51236 +Mar 10 08:27:10 example-host sshd[1243]: Failed publickey for invalid user svc-backup from 203.0.113.40 port 51240 ssh2 +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 +Mar 10 08:29:50 example-host pam_unix(sudo:session): session opened for user root by alice(uid=0) +Mar 10 08:30:12 example-host sshd[1244]: Connection closed by authenticating user alice 203.0.113.50 port 51290 [preauth] +Mar 10 08:31:18 example-host sshd[1245]: Timeout, client not responding from 203.0.113.51 port 51291 diff --git a/tests/fixtures/report_contracts/syslog_legacy/report.json b/tests/fixtures/report_contracts/syslog_legacy/report.json new file mode 100644 index 0000000..c80f7c4 --- /dev/null +++ b/tests/fixtures/report_contracts/syslog_legacy/report.json @@ -0,0 +1,65 @@ +{ + "tool": "LogLens", + "input": "tests/fixtures/report_contracts/syslog_legacy/input.log", + "input_mode": "syslog_legacy", + "assume_year": 2026, + "timezone_present": false, + "parser_quality": { + "total_lines": 16, + "parsed_lines": 14, + "unparsed_lines": 2, + "parse_success_rate": 0.8750, + "top_unknown_patterns": [ + {"pattern": "sshd_connection_closed_preauth", "count": 1}, + {"pattern": "sshd_timeout_or_disconnection", "count": 1} + ] + }, + "parsed_event_count": 14, + "warning_count": 2, + "finding_count": 3, + "event_counts": [ + {"event_type": "ssh_failed_password", "count": 4}, + {"event_type": "ssh_accepted_password", "count": 1}, + {"event_type": "ssh_invalid_user", "count": 3}, + {"event_type": "ssh_failed_publickey", "count": 1}, + {"event_type": "pam_auth_failure", "count": 1}, + {"event_type": "session_opened", "count": 1}, + {"event_type": "sudo_command", "count": 3} + ], + "findings": [ + { + "rule": "brute_force", + "subject_kind": "source_ip", + "subject": "203.0.113.10", + "event_count": 5, + "window_start": "2026-03-10 08:11:22", + "window_end": "2026-03-10 08:18:05", + "usernames": [], + "summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes." + }, + { + "rule": "multi_user_probing", + "subject_kind": "source_ip", + "subject": "203.0.113.10", + "event_count": 5, + "window_start": "2026-03-10 08:11:22", + "window_end": "2026-03-10 08:18:05", + "usernames": ["admin", "deploy", "guest", "root", "test"], + "summary": "203.0.113.10 targeted 5 usernames within 15 minutes." + }, + { + "rule": "sudo_burst", + "subject_kind": "username", + "subject": "alice", + "event_count": 3, + "window_start": "2026-03-10 08:21:00", + "window_end": "2026-03-10 08:24:15", + "usernames": [], + "summary": "alice ran 3 sudo commands within 5 minutes." + } + ], + "warnings": [ + {"line_number": 15, "reason": "unrecognized auth pattern: sshd_connection_closed_preauth"}, + {"line_number": 16, "reason": "unrecognized auth pattern: sshd_timeout_or_disconnection"} + ] +} diff --git a/tests/fixtures/report_contracts/syslog_legacy/report.md b/tests/fixtures/report_contracts/syslog_legacy/report.md new file mode 100644 index 0000000..b26d176 --- /dev/null +++ b/tests/fixtures/report_contracts/syslog_legacy/report.md @@ -0,0 +1,49 @@ +# LogLens Report + +## Summary + +- Input: `tests/fixtures/report_contracts/syslog_legacy/input.log` +- Input mode: syslog_legacy +- Assume year: 2026 +- Timezone present: false +- Total lines: 16 +- Parsed lines: 14 +- Unparsed lines: 2 +- Parse success rate: 87.50% +- Parsed events: 14 +- Findings: 3 +- Parser warnings: 2 + +## Findings + +| Rule | Subject | Count | Window | Notes | +| --- | --- | ---: | --- | --- | +| brute_force | 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. | +| multi_user_probing | 203.0.113.10 | 5 | 2026-03-10 08:11:22 -> 2026-03-10 08:18:05 | 203.0.113.10 targeted 5 usernames within 15 minutes. Usernames: admin, deploy, guest, root, test | +| sudo_burst | alice | 3 | 2026-03-10 08:21:00 -> 2026-03-10 08:24:15 | alice ran 3 sudo commands within 5 minutes. | + +## Event Counts + +| Event Type | Count | +| --- | ---: | +| ssh_failed_password | 4 | +| ssh_accepted_password | 1 | +| ssh_invalid_user | 3 | +| ssh_failed_publickey | 1 | +| pam_auth_failure | 1 | +| session_opened | 1 | +| sudo_command | 3 | + +## Parser Quality + +| Unknown Pattern | Count | +| --- | ---: | +| sshd_connection_closed_preauth | 1 | +| sshd_timeout_or_disconnection | 1 | + +## Parser Warnings + +| Line | Reason | +| ---: | --- | +| 15 | unrecognized auth pattern: sshd_connection_closed_preauth | +| 16 | 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 new file mode 100644 index 0000000..1459da3 --- /dev/null +++ b/tests/fixtures/report_contracts/syslog_legacy/warnings.csv @@ -0,0 +1,3 @@ +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 0060f42..2d96fe1 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()); @@ -115,6 +150,47 @@ int main(int argc, char* argv[]) { const auto syslog_markdown = read_file(syslog_cli_out / "report.md"); const auto syslog_json = read_file(syslog_cli_out / "report.json"); expect_report_core_fields(syslog_markdown, syslog_json, "syslog_legacy", true, false); + expect(!std::filesystem::exists(syslog_cli_out / "findings.csv"), + "did not expect findings.csv without explicit csv flag"); + 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-legacy --year=2026 --csv " + + quote_argument(sample_log) + + " " + quote_argument(csv_out)) + .c_str()); + expect(csv_exit == 0, "expected syslog CSV CLI run to succeed"); + const auto findings_csv = read_file(csv_out / "findings.csv"); + const auto warnings_csv = read_file(csv_out / "warnings.csv"); + expect(findings_csv.find("rule,subject_kind,subject,event_count,window_start,window_end,usernames,summary") + == 0, + "expected findings csv header"); + 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,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 config_run_out = output_dir / "config_run"; std::filesystem::create_directories(config_run_out); @@ -130,7 +206,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()); @@ -139,31 +215,57 @@ int main(int argc, char* argv[]) { const auto journalctl_markdown = read_file(journalctl_out / "report.md"); const auto journalctl_json = read_file(journalctl_out / "report.json"); expect_report_core_fields(journalctl_markdown, journalctl_json, "journalctl_short_full", false, true); + expect(!std::filesystem::exists(journalctl_out / "findings.csv"), + "did not expect journalctl findings.csv without explicit csv flag"); + expect(!std::filesystem::exists(journalctl_out / "warnings.csv"), + "did not expect journalctl warnings.csv without explicit csv flag"); const auto missing_year_out = output_dir / "missing_year"; std::filesystem::create_directories(missing_year_out); - const auto missing_year_stdout = output_dir / "missing_year_stdout.txt"; - const auto missing_year_stderr = output_dir / "missing_year_stderr.txt"; const int missing_year_exit = std::system(build_command( quote_argument(loglens_exe) + " --mode syslog " + quote_argument(sample_log) - + " " + quote_argument(missing_year_out), - &missing_year_stdout, - &missing_year_stderr) + + " " + quote_argument(missing_year_out)) .c_str()); expect(missing_year_exit != 0, "expected syslog mode without year to fail"); - const auto missing_year_error = read_file(missing_year_stderr); - expect(missing_year_error.find("--year") != std::string::npos - || missing_year_error.find("assume_year") != std::string::npos, - "expected missing-year error to mention year requirements"); + + 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" @@ -177,22 +279,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 auto invalid_stdout = output_dir / "invalid_stdout.txt"; - const auto invalid_stderr = output_dir / "invalid_stderr.txt"; 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), - &invalid_stdout, + nullptr, &invalid_stderr) .c_str()); expect(invalid_exit != 0, "expected invalid config CLI run to fail"); - - const auto invalid_error = read_file(invalid_stderr); - expect(invalid_error.find("assume_year") != std::string::npos, - "expected CLI error output to mention the failing config field"); + 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 03c86c5..0804791 100644 --- a/tests/test_detector.cpp +++ b/tests/test_detector.cpp @@ -84,6 +84,16 @@ std::vector build_publickey_bruteforce_candidate_events() { "Mar 10 08:18:05 example-host sshd[1238]: Failed publickey for root from 203.0.113.10 port 51060 ssh2\n"); } +std::vector build_publickey_success_candidate_events() { + return parse_events( + make_syslog_config(), + "Mar 10 08:11:22 example-host sshd[1234]: Failed password for root from 203.0.113.10 port 51022 ssh2\n" + "Mar 10 08:12:05 example-host sshd[1235]: Failed password for root from 203.0.113.10 port 51030 ssh2\n" + "Mar 10 08:13:10 example-host sshd[1236]: Failed password for root from 203.0.113.10 port 51040 ssh2\n" + "Mar 10 08:14:44 example-host sshd[1237]: Failed password for root from 203.0.113.10 port 51050 ssh2\n" + "Mar 10 08:18:05 example-host sshd[1238]: Accepted publickey for alice from 203.0.113.10 port 51060 ssh2: ED25519 SHA256:SANITIZEDKEY\n"); +} + std::vector build_pam_bruteforce_candidate_events() { return parse_events( make_syslog_config(), @@ -147,16 +157,32 @@ 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"); + "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() == 2, "expected two auth signals"); + 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"); @@ -173,6 +199,19 @@ void test_failed_publickey_contributes_to_bruteforce_by_default() { expect(brute_force->event_count == 5, "expected publickey evidence to raise brute force count to five"); } +void test_accepted_publickey_success_stays_out_of_failure_signals() { + const auto events = build_publickey_success_candidate_events(); + const auto signals = loglens::build_auth_signals(events, loglens::DetectorConfig{}.auth_signal_mappings); + + expect(signals.size() == 4, "expected accepted publickey success to stay out of the signal layer"); + + 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 accepted publickey success to stay out of brute-force counting"); +} + 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); @@ -343,6 +382,7 @@ int main() { test_custom_thresholds(); test_auth_signal_defaults(); test_failed_publickey_contributes_to_bruteforce_by_default(); + test_accepted_publickey_success_stays_out_of_failure_signals(); test_sudo_signals_include_command_and_session_opened(); test_sudo_burst_behavior_is_preserved_with_signal_layer(); test_unsupported_pam_session_close_remains_telemetry_only(); diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index d4eca9e..9ee2169 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -21,6 +21,12 @@ loglens::AuthLogParser make_syslog_parser() { 2026}); } +loglens::AuthLogParser make_journalctl_parser() { + return loglens::AuthLogParser(loglens::ParserConfig{ + loglens::InputMode::JournalctlShortFull, + std::nullopt}); +} + std::filesystem::path repo_root() { const std::filesystem::path source_path{__FILE__}; std::vector candidates; @@ -95,6 +101,32 @@ void test_success_event() { expect(event->event_type == loglens::EventType::SshAcceptedPassword, "expected ssh success type"); } +void test_accepted_publickey_success_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 11 10:00:01 example-host sshd[2100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY", + 4); + + expect(event.has_value(), "expected accepted publickey event"); + expect(event->username == "alice", "expected accepted publickey username"); + expect(event->source_ip == "203.0.113.70", "expected accepted publickey source ip"); + expect(event->event_type == loglens::EventType::SshAcceptedPublicKey, + "expected accepted publickey event type"); +} + +void test_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( @@ -107,6 +139,55 @@ void test_sudo_event() { 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( @@ -119,6 +200,32 @@ void test_failed_publickey_event() { 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( @@ -132,6 +239,19 @@ void test_pam_auth_failure_event() { expect(event->event_type == loglens::EventType::PamAuthFailure, "expected pam auth failure type"); } +void test_pam_sss_received_failure_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 11 10:02:25 example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure)", + 7); + + expect(event.has_value(), "expected pam_sss received failure event"); + expect(event->program == "pam_sss(sshd:auth)", "expected pam_sss auth program"); + expect(event->username == "dave", "expected pam_sss username"); + expect(event->source_ip.empty(), "expected pam_sss received failure to stay source-less"); + expect(event->event_type == loglens::EventType::PamAuthFailure, "expected pam_sss failure type"); +} + void test_session_opened_event() { const auto parser = make_syslog_parser(); const auto event = parser.parse_line( @@ -161,6 +281,130 @@ void test_journalctl_short_full_event() { "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")); + + expect(result.events.size() == 8, "expected eight recognized syslog auth-family events"); + expect(result.warnings.size() == 5, "expected five syslog auth-family warnings"); + expect(result.quality.total_lines == 13, "expected thirteen syslog auth-family lines"); + expect(result.quality.parsed_lines == 8, "expected eight parsed syslog auth-family lines"); + expect(result.quality.unparsed_lines == 5, "expected five unparsed syslog auth-family lines"); + expect_close(result.quality.parse_success_rate, 8.0 / 13.0, 1e-9, + "expected syslog auth-family parse success rate"); + + expect(result.events[0].event_type == loglens::EventType::SshAcceptedPublicKey, + "expected accepted publickey auth-family event"); + expect(result.events[0].source_ip == "203.0.113.70", "expected accepted publickey source ip"); + expect(result.events[1].event_type == loglens::EventType::SshAcceptedPassword, + "expected accepted password auth-family event"); + expect(result.events[1].username == "bob", "expected accepted password username"); + expect(result.events[2].event_type == loglens::EventType::SshFailedPublicKey, + "expected failed publickey auth-family event"); + expect(result.events[2].username == "svc-deploy", "expected failed publickey username"); + expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, + "expected pam_faillock preauth auth-family event"); + expect(result.events[3].username == "alice", "expected pam_faillock preauth username"); + expect(result.events[3].source_ip == "203.0.113.71", "expected pam_faillock preauth source ip"); + expect(result.events[4].event_type == loglens::EventType::PamAuthFailure, + "expected pam_faillock authfail auth-family event"); + expect(result.events[4].username == "bob", "expected pam_faillock authfail username"); + expect(result.events[4].source_ip == "203.0.113.72", "expected pam_faillock authfail source ip"); + expect(result.events[5].event_type == loglens::EventType::PamAuthFailure, + "expected pam_unix auth-family event"); + expect(result.events[5].username == "carol", "expected pam_unix auth-family username"); + expect(result.events[5].source_ip == "203.0.113.75", "expected pam_unix auth-family source ip"); + expect(result.events[6].event_type == loglens::EventType::PamAuthFailure, + "expected pam_sss failure auth-family event"); + expect(result.events[6].username == "dave", "expected pam_sss failure username"); + expect(result.events[6].source_ip.empty(), "expected pam_sss failure fixture to stay source-less"); + expect(result.events[7].event_type == loglens::EventType::SessionOpened, + "expected pam_unix session-opened auth-family event"); + expect(result.events[7].username == "erin", "expected pam_unix session-opened username"); + + expect(result.quality.top_unknown_patterns.size() == 5, "expected five syslog auth-family buckets"); + expect(result.quality.top_unknown_patterns[0].pattern == "pam_faillock_authsucc", + "expected pam_faillock authsucc telemetry bucket"); + expect(result.quality.top_unknown_patterns[0].count == 1, "expected one pam_faillock authsucc line"); + expect(result.quality.top_unknown_patterns[1].pattern == "pam_faillock_other", + "expected pam_faillock other telemetry bucket"); + expect(result.quality.top_unknown_patterns[1].count == 1, "expected one pam_faillock other line"); + expect(result.quality.top_unknown_patterns[2].pattern == "pam_sss_authinfo_unavail", + "expected pam_sss authinfo-unavail telemetry bucket"); + expect(result.quality.top_unknown_patterns[2].count == 1, "expected one pam_sss authinfo-unavail line"); + expect(result.quality.top_unknown_patterns[3].pattern == "pam_sss_unknown_user", + "expected pam_sss unknown-user telemetry bucket"); + expect(result.quality.top_unknown_patterns[3].count == 1, "expected one pam_sss unknown-user line"); + expect(result.quality.top_unknown_patterns[4].pattern == "pam_unix_other", + "expected pam_unix other telemetry bucket"); + expect(result.quality.top_unknown_patterns[4].count == 1, "expected one pam_unix other line"); +} + +void test_journalctl_auth_family_fixture_file() { + const auto parser = make_journalctl_parser(); + const auto result = parser.parse_file(asset_path("parser_auth_families_journalctl_short_full.log")); + + expect(result.events.size() == 8, "expected eight recognized journalctl auth-family events"); + expect(result.warnings.size() == 5, "expected five journalctl auth-family warnings"); + expect(result.quality.total_lines == 13, "expected thirteen journalctl auth-family lines"); + expect(result.quality.parsed_lines == 8, "expected eight parsed journalctl auth-family lines"); + expect(result.quality.unparsed_lines == 5, "expected five unparsed journalctl auth-family lines"); + expect_close(result.quality.parse_success_rate, 8.0 / 13.0, 1e-9, + "expected journalctl auth-family parse success rate"); + + expect(result.events[0].event_type == loglens::EventType::SshAcceptedPublicKey, + "expected journalctl accepted publickey auth-family event"); + expect(result.events[0].source_ip == "203.0.113.70", "expected journalctl accepted publickey source ip"); + expect(result.events[1].event_type == loglens::EventType::SshAcceptedPassword, + "expected journalctl accepted password auth-family event"); + expect(result.events[2].event_type == loglens::EventType::SshFailedPublicKey, + "expected journalctl failed publickey auth-family event"); + expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, + "expected journalctl pam_faillock preauth auth-family event"); + expect(result.events[4].event_type == loglens::EventType::PamAuthFailure, + "expected journalctl pam_faillock authfail auth-family event"); + expect(result.events[5].event_type == loglens::EventType::PamAuthFailure, + "expected journalctl pam_unix auth-family event"); + expect(result.events[6].event_type == loglens::EventType::PamAuthFailure, + "expected journalctl pam_sss failure auth-family event"); + expect(result.events[6].source_ip.empty(), "expected journalctl pam_sss failure fixture to stay source-less"); + expect(result.events[7].event_type == loglens::EventType::SessionOpened, + "expected journalctl pam_unix session-opened auth-family event"); + + expect(result.quality.top_unknown_patterns.size() == 5, "expected five journalctl auth-family buckets"); + expect(result.quality.top_unknown_patterns[0].pattern == "pam_faillock_authsucc", + "expected journalctl pam_faillock authsucc telemetry bucket"); + expect(result.quality.top_unknown_patterns[0].count == 1, "expected one journalctl pam_faillock authsucc line"); + expect(result.quality.top_unknown_patterns[1].pattern == "pam_faillock_other", + "expected journalctl pam_faillock other telemetry bucket"); + expect(result.quality.top_unknown_patterns[1].count == 1, "expected one journalctl pam_faillock other line"); + expect(result.quality.top_unknown_patterns[2].pattern == "pam_sss_authinfo_unavail", + "expected journalctl pam_sss authinfo-unavail telemetry bucket"); + expect(result.quality.top_unknown_patterns[2].count == 1, "expected one journalctl pam_sss authinfo-unavail line"); + expect(result.quality.top_unknown_patterns[3].pattern == "pam_sss_unknown_user", + "expected journalctl pam_sss unknown-user telemetry bucket"); + expect(result.quality.top_unknown_patterns[3].count == 1, "expected one journalctl pam_sss unknown-user line"); + expect(result.quality.top_unknown_patterns[4].pattern == "pam_unix_other", + "expected journalctl pam_unix other telemetry bucket"); + expect(result.quality.top_unknown_patterns[4].count == 1, "expected one journalctl pam_unix other line"); +} + void test_malformed_line() { const auto parser = make_syslog_parser(); std::string error; @@ -216,6 +460,24 @@ void test_stream_warnings_and_metadata() { "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, @@ -239,16 +501,28 @@ void test_journalctl_metadata() { "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() == 6, "expected six recognized syslog fixture events"); - expect(result.warnings.size() == 6, "expected six syslog fixture warnings"); - expect(result.quality.total_lines == 12, "expected twelve syslog fixture lines"); - expect(result.quality.parsed_lines == 6, "expected six parsed syslog fixture lines"); - expect(result.quality.unparsed_lines == 6, "expected six unparsed syslog fixture lines"); - expect_close(result.quality.parse_success_rate, 0.5, 1e-9, "expected syslog fixture parse success rate"); + expect(result.events.size() == 15, "expected fifteen recognized syslog fixture events"); + expect(result.warnings.size() == 8, "expected eight syslog fixture warnings"); + 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, 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"); @@ -256,19 +530,47 @@ void test_syslog_fixture_matrix_file() { expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, "expected pam auth failure variant"); expect(result.events[4].event_type == loglens::EventType::SessionOpened, "expected sudo session-opened variant"); expect(result.events[5].event_type == loglens::EventType::SessionOpened, "expected su-l session-opened variant"); + expect(result.events[6].event_type == loglens::EventType::SshAcceptedPassword, "expected accepted password variant"); + expect(result.events[7].event_type == loglens::EventType::SshAcceptedPublicKey, "expected accepted publickey variant"); expect(result.events[4].username == "alice", "expected sudo session actor username"); expect(result.events[5].username == "bob", "expected su-l session actor username"); - - expect(result.quality.top_unknown_patterns.size() == 3, "expected three unknown syslog buckets"); + expect(result.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", "expected preauth connection-close syslog bucket"); expect(result.quality.top_unknown_patterns[0].count == 3, "expected three preauth connection-close syslog lines"); expect(result.quality.top_unknown_patterns[1].pattern == "sshd_timeout_or_disconnection", "expected timeout/disconnection syslog bucket"); - expect(result.quality.top_unknown_patterns[1].count == 2, "expected two timeout/disconnection syslog lines"); + expect(result.quality.top_unknown_patterns[1].count == 3, "expected three timeout/disconnection syslog lines"); expect(result.quality.top_unknown_patterns[2].pattern == "pam_unix_other", "expected unsupported pam_unix syslog bucket"); expect(result.quality.top_unknown_patterns[2].count == 1, "expected one unsupported pam_unix syslog line"); + expect(result.quality.top_unknown_patterns[3].pattern == "sshd_other", + "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() { @@ -277,12 +579,12 @@ void test_journalctl_fixture_matrix_file() { std::nullopt}); const auto result = parser.parse_file(asset_path("parser_fixture_matrix_journalctl_short_full.log")); - expect(result.events.size() == 6, "expected six recognized journalctl fixture events"); - expect(result.warnings.size() == 6, "expected six journalctl fixture warnings"); - expect(result.quality.total_lines == 12, "expected twelve journalctl fixture lines"); - expect(result.quality.parsed_lines == 6, "expected six parsed journalctl fixture lines"); - expect(result.quality.unparsed_lines == 6, "expected six unparsed journalctl fixture lines"); - expect_close(result.quality.parse_success_rate, 0.5, 1e-9, "expected journalctl fixture parse success rate"); + expect(result.events.size() == 15, "expected fifteen recognized journalctl fixture events"); + expect(result.warnings.size() == 8, "expected eight journalctl fixture warnings"); + 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, 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"); @@ -290,17 +592,36 @@ void test_journalctl_fixture_matrix_file() { expect(result.events[3].event_type == loglens::EventType::PamAuthFailure, "expected journalctl pam auth failure variant"); expect(result.events[4].event_type == loglens::EventType::SessionOpened, "expected journalctl sudo session-opened variant"); expect(result.events[5].event_type == loglens::EventType::SessionOpened, "expected journalctl su-l session-opened variant"); - - expect(result.quality.top_unknown_patterns.size() == 3, "expected three unknown journalctl buckets"); + expect(result.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", "expected preauth connection-close journalctl bucket"); expect(result.quality.top_unknown_patterns[0].count == 3, "expected three preauth connection-close journalctl lines"); expect(result.quality.top_unknown_patterns[1].pattern == "sshd_timeout_or_disconnection", "expected timeout/disconnection journalctl bucket"); - expect(result.quality.top_unknown_patterns[1].count == 2, "expected two timeout/disconnection journalctl lines"); + expect(result.quality.top_unknown_patterns[1].count == 3, "expected three timeout/disconnection journalctl lines"); expect(result.quality.top_unknown_patterns[2].pattern == "pam_unix_other", "expected unsupported pam_unix journalctl bucket"); expect(result.quality.top_unknown_patterns[2].count == 1, "expected one unsupported pam_unix journalctl line"); + expect(result.quality.top_unknown_patterns[3].pattern == "sshd_other", + "expected unsupported sshd journalctl bucket"); + expect(result.quality.top_unknown_patterns[3].count == 1, "expected one unsupported sshd journalctl line"); } } // namespace @@ -309,15 +630,29 @@ 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_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..53c419d --- /dev/null +++ b/tests/test_report.cpp @@ -0,0 +1,253 @@ +#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_removes_stale_csv_when_csv_is_disabled() { + const auto output_directory = std::filesystem::current_path() / "report_stale_csv_cleanup_test"; + std::filesystem::remove_all(output_directory); + + loglens::write_reports(make_report_data(), output_directory, true); + expect(std::filesystem::exists(output_directory / "findings.csv"), + "expected findings.csv after csv-enabled report run"); + expect(std::filesystem::exists(output_directory / "warnings.csv"), + "expected warnings.csv after csv-enabled report run"); + + loglens::write_reports(make_report_data(), output_directory, false); + + expect(!std::filesystem::exists(output_directory / "findings.csv"), + "expected stale findings.csv to be removed when csv is disabled"); + expect(!std::filesystem::exists(output_directory / "warnings.csv"), + "expected stale warnings.csv to be removed when csv is disabled"); + + std::filesystem::remove_all(output_directory); +} + +void test_write_reports_reports_stale_csv_cleanup_failure() { + const auto output_directory = std::filesystem::current_path() / "report_stale_csv_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, false); + } 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 stale findings.csv cannot be removed"); + expect(message.find("findings.csv") != std::string::npos, + "expected stale csv cleanup 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_removes_stale_csv_when_csv_is_disabled(); + test_write_reports_reports_stale_csv_cleanup_failure(); + return 0; +} diff --git a/tests/test_report_contracts.cpp b/tests/test_report_contracts.cpp new file mode 100644 index 0000000..c1d5476 --- /dev/null +++ b/tests/test_report_contracts.cpp @@ -0,0 +1,339 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +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::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()); +} + +std::string normalize_line_endings(std::string value) { + value.erase(std::remove(value.begin(), value.end(), '\r'), value.end()); + return value; +} + +std::vector split_lines(const std::string& content) { + std::vector lines; + std::string current; + + for (const char ch : normalize_line_endings(content)) { + if (ch == '\n') { + lines.push_back(current); + current.clear(); + } else { + current += ch; + } + } + + if (!current.empty()) { + lines.push_back(current); + } + + return lines; +} + +std::string trim(std::string_view value) { + std::size_t start = 0; + while (start < value.size() && (value[start] == ' ' || value[start] == '\t')) { + ++start; + } + + std::size_t end = value.size(); + while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t')) { + --end; + } + + return std::string(value.substr(start, end - start)); +} + +bool starts_with(std::string_view value, std::string_view prefix) { + return value.size() >= prefix.size() && value.substr(0, prefix.size()) == prefix; +} + +bool is_markdown_separator_row(std::string_view line) { + return starts_with(line, "| ---"); +} + +std::vector extract_markdown_contract_lines(const std::string& markdown) { + std::vector contract_lines; + + for (const auto& raw_line : split_lines(markdown)) { + const auto line = trim(raw_line); + if (line.empty() || is_markdown_separator_row(line)) { + continue; + } + + if (line == "# LogLens Report" + || starts_with(line, "## ") + || starts_with(line, "- Input: ") + || starts_with(line, "- Input mode: ") + || starts_with(line, "- Assume year: ") + || starts_with(line, "- Timezone present: ") + || starts_with(line, "- Total lines: ") + || starts_with(line, "- Parsed lines: ") + || starts_with(line, "- Unparsed lines: ") + || starts_with(line, "- Parse success rate: ") + || starts_with(line, "- Parsed events: ") + || starts_with(line, "- Findings: ") + || starts_with(line, "- Parser warnings: ") + || starts_with(line, "| ") + || starts_with(line, "No configured detections matched") + || starts_with(line, "All analyzed lines matched") + || starts_with(line, "No malformed lines were skipped")) { + contract_lines.push_back(line); + } + } + + return contract_lines; +} + +std::vector extract_json_contract_lines(const std::string& json) { + std::vector contract_lines; + + for (const auto& raw_line : split_lines(json)) { + const auto line = trim(raw_line); + if (line.empty()) { + continue; + } + + if (starts_with(line, "\"tool\": ") + || starts_with(line, "\"input\": ") + || starts_with(line, "\"input_mode\": ") + || starts_with(line, "\"assume_year\": ") + || starts_with(line, "\"timezone_present\": ") + || starts_with(line, "\"total_lines\": ") + || starts_with(line, "\"parsed_lines\": ") + || starts_with(line, "\"unparsed_lines\": ") + || starts_with(line, "\"parse_success_rate\": ") + || starts_with(line, "\"parsed_event_count\": ") + || starts_with(line, "\"warning_count\": ") + || starts_with(line, "\"finding_count\": ") + || starts_with(line, "\"host_summaries\": ") + || starts_with(line, "\"hostname\": ") + || starts_with(line, "{\"pattern\": ") + || starts_with(line, "{\"event_type\": ") + || starts_with(line, "\"rule\": ") + || starts_with(line, "\"subject_kind\": ") + || starts_with(line, "\"subject\": ") + || starts_with(line, "\"event_count\": ") + || starts_with(line, "\"window_start\": ") + || starts_with(line, "\"window_end\": ") + || starts_with(line, "\"usernames\": ") + || starts_with(line, "\"summary\": ") + || starts_with(line, "{\"line_number\": ")) { + contract_lines.push_back(line); + } + } + + return contract_lines; +} + +std::vector extract_csv_contract_lines(const std::string& csv) { + std::vector lines; + for (const auto& raw_line : split_lines(csv)) { + if (!raw_line.empty()) { + lines.push_back(raw_line); + } + } + return lines; +} + +std::string quote_argument(std::string_view value) { + return "\"" + std::string(value) + "\""; +} + +std::string build_command(const std::string& invocation) { +#ifdef _WIN32 + return "cmd /c \"" + invocation + "\""; +#else + return invocation; +#endif +} + +void expect_equal_lines(const std::vector& actual, + const std::vector& expected, + const std::string& message) { + if (actual == expected) { + return; + } + + std::string details = message + "\nexpected:\n"; + for (const auto& line : expected) { + details += " " + line + '\n'; + } + details += "actual:\n"; + for (const auto& line : actual) { + details += " " + line + '\n'; + } + + throw std::runtime_error(details); +} + +void run_report_contract_case(const std::filesystem::path& loglens_exe, + const std::filesystem::path& fixture_directory, + const std::filesystem::path& output_root, + const std::string& mode_argument, + const std::string& extra_arguments = {}, + bool expect_csv = false) { + const auto repo = repo_root(); + const auto relative_input = std::filesystem::relative(fixture_directory / "input.log", repo).generic_string(); + const auto case_output = output_root / fixture_directory.filename(); + + std::filesystem::remove_all(case_output); + std::filesystem::create_directories(case_output); + + std::string invocation = quote_argument(loglens_exe.generic_string()) + + " --mode " + mode_argument; + if (!extra_arguments.empty()) { + invocation += " " + extra_arguments; + } + invocation += " " + quote_argument(relative_input) + + " " + quote_argument(case_output.generic_string()); + + const int exit_code = std::system(build_command(invocation).c_str()); + expect(exit_code == 0, "expected report contract CLI run to succeed for " + fixture_directory.filename().string()); + + const auto actual_markdown = read_file(case_output / "report.md"); + const auto actual_json = read_file(case_output / "report.json"); + const auto golden_markdown = read_file(fixture_directory / "report.md"); + const auto golden_json = read_file(fixture_directory / "report.json"); + + expect_equal_lines( + extract_markdown_contract_lines(actual_markdown), + extract_markdown_contract_lines(golden_markdown), + "markdown contract mismatch for " + fixture_directory.filename().string()); + expect_equal_lines( + extract_json_contract_lines(actual_json), + extract_json_contract_lines(golden_json), + "json contract mismatch for " + fixture_directory.filename().string()); + + const auto golden_findings_csv = fixture_directory / "findings.csv"; + const auto golden_warnings_csv = fixture_directory / "warnings.csv"; + if (expect_csv) { + expect(std::filesystem::exists(golden_findings_csv), + "expected golden findings.csv for " + fixture_directory.filename().string()); + expect(std::filesystem::exists(case_output / "findings.csv"), + "expected findings.csv for " + fixture_directory.filename().string()); + expect_equal_lines( + extract_csv_contract_lines(read_file(case_output / "findings.csv")), + extract_csv_contract_lines(read_file(golden_findings_csv)), + "findings csv contract mismatch for " + fixture_directory.filename().string()); + } else { + expect(!std::filesystem::exists(case_output / "findings.csv"), + "did not expect findings.csv for " + fixture_directory.filename().string()); + } + + if (expect_csv) { + expect(std::filesystem::exists(golden_warnings_csv), + "expected golden warnings.csv for " + fixture_directory.filename().string()); + expect(std::filesystem::exists(case_output / "warnings.csv"), + "expected warnings.csv for " + fixture_directory.filename().string()); + expect_equal_lines( + extract_csv_contract_lines(read_file(case_output / "warnings.csv")), + extract_csv_contract_lines(read_file(golden_warnings_csv)), + "warnings csv contract mismatch for " + fixture_directory.filename().string()); + } else { + expect(!std::filesystem::exists(case_output / "warnings.csv"), + "did not expect warnings.csv for " + fixture_directory.filename().string()); + } +} + +} // namespace + +int main(int argc, char* argv[]) { + if (argc != 3) { + throw std::runtime_error("expected arguments: "); + } + + const auto original_cwd = std::filesystem::current_path(); + const auto repo = repo_root(); + std::filesystem::current_path(repo); + + try { + const std::filesystem::path loglens_exe = std::filesystem::absolute(argv[1]); + const std::filesystem::path output_root = std::filesystem::absolute(argv[2]); + const auto fixture_root = repo / "tests" / "fixtures" / "report_contracts"; + + run_report_contract_case( + loglens_exe, + fixture_root / "syslog_legacy", + output_root, + "syslog", + "--year 2026"); + run_report_contract_case( + loglens_exe, + fixture_root / "journalctl_short_full", + output_root, + "journalctl-short-full"); + run_report_contract_case( + loglens_exe, + fixture_root / "multi_host_syslog_legacy", + output_root, + "syslog", + "--year 2026"); + run_report_contract_case( + loglens_exe, + fixture_root / "multi_host_journalctl_short_full", + output_root, + "journalctl-short-full"); + run_report_contract_case( + loglens_exe, + fixture_root / "syslog_legacy", + output_root, + "syslog", + "--year 2026 --csv", + true); + run_report_contract_case( + loglens_exe, + fixture_root / "multi_host_syslog_legacy", + output_root, + "syslog", + "--year 2026 --csv", + true); + } catch (...) { + std::filesystem::current_path(original_cwd); + throw; + } + + std::filesystem::current_path(original_cwd); + return 0; +} From c2ca64b236120510ed9dbc146977e166e535f952 Mon Sep 17 00:00:00 2001 From: stacknil Date: Thu, 21 May 2026 22:11:08 +0800 Subject: [PATCH 3/4] chore: normalize source line endings --- src/signal.cpp | 166 +++++------ tests/test_detector.cpp | 646 ++++++++++++++++++++-------------------- tests/test_parser.cpp | 430 +++++++++++++------------- 3 files changed, 621 insertions(+), 621 deletions(-) diff --git a/src/signal.cpp b/src/signal.cpp index 16909ad..414a3d7 100644 --- a/src/signal.cpp +++ b/src/signal.cpp @@ -1,58 +1,58 @@ -#include "signal.hpp" - -#include - -namespace loglens { -namespace { - -struct SignalMapping { - AuthSignalKind signal_kind = AuthSignalKind::Unknown; - bool counts_as_attempt_evidence = false; - bool counts_as_terminal_auth_failure = false; - bool counts_as_sudo_burst_evidence = false; -}; - -std::optional signal_mapping_for_event(const Event& event, const AuthSignalConfig& config) { - switch (event.event_type) { - case EventType::SshFailedPassword: - return SignalMapping{ - AuthSignalKind::SshFailedPassword, - config.ssh_failed_password.counts_as_attempt_evidence, - config.ssh_failed_password.counts_as_terminal_auth_failure, - false}; - case EventType::SshInvalidUser: - return SignalMapping{ - AuthSignalKind::SshInvalidUser, - config.ssh_invalid_user.counts_as_attempt_evidence, - config.ssh_invalid_user.counts_as_terminal_auth_failure, - false}; - case EventType::SshFailedPublicKey: - return SignalMapping{ - AuthSignalKind::SshFailedPublicKey, - config.ssh_failed_publickey.counts_as_attempt_evidence, - config.ssh_failed_publickey.counts_as_terminal_auth_failure, - false}; - case EventType::PamAuthFailure: - return SignalMapping{ - AuthSignalKind::PamAuthFailure, - config.pam_auth_failure.counts_as_attempt_evidence, - config.pam_auth_failure.counts_as_terminal_auth_failure, - false}; - case EventType::SudoCommand: - return SignalMapping{ - AuthSignalKind::SudoCommand, - false, - false, - true}; - case EventType::SessionOpened: - if (event.program == "pam_unix(sudo:session)") { - return SignalMapping{ - AuthSignalKind::SudoSessionOpened, - false, - false, - false}; - } - return std::nullopt; +#include "signal.hpp" + +#include + +namespace loglens { +namespace { + +struct SignalMapping { + AuthSignalKind signal_kind = AuthSignalKind::Unknown; + bool counts_as_attempt_evidence = false; + bool counts_as_terminal_auth_failure = false; + bool counts_as_sudo_burst_evidence = false; +}; + +std::optional signal_mapping_for_event(const Event& event, const AuthSignalConfig& config) { + switch (event.event_type) { + case EventType::SshFailedPassword: + return SignalMapping{ + AuthSignalKind::SshFailedPassword, + config.ssh_failed_password.counts_as_attempt_evidence, + config.ssh_failed_password.counts_as_terminal_auth_failure, + false}; + case EventType::SshInvalidUser: + return SignalMapping{ + AuthSignalKind::SshInvalidUser, + config.ssh_invalid_user.counts_as_attempt_evidence, + config.ssh_invalid_user.counts_as_terminal_auth_failure, + false}; + case EventType::SshFailedPublicKey: + return SignalMapping{ + AuthSignalKind::SshFailedPublicKey, + config.ssh_failed_publickey.counts_as_attempt_evidence, + config.ssh_failed_publickey.counts_as_terminal_auth_failure, + false}; + case EventType::PamAuthFailure: + return SignalMapping{ + AuthSignalKind::PamAuthFailure, + config.pam_auth_failure.counts_as_attempt_evidence, + config.pam_auth_failure.counts_as_terminal_auth_failure, + false}; + case EventType::SudoCommand: + return SignalMapping{ + AuthSignalKind::SudoCommand, + false, + false, + true}; + case EventType::SessionOpened: + if (event.program == "pam_unix(sudo:session)") { + return SignalMapping{ + AuthSignalKind::SudoSessionOpened, + false, + false, + false}; + } + return std::nullopt; case EventType::Unknown: case EventType::SshAcceptedPassword: case EventType::SshAcceptedPublicKey: @@ -60,31 +60,31 @@ std::optional signal_mapping_for_event(const Event& event, const return std::nullopt; } } - -} // namespace - -std::vector build_auth_signals(const std::vector& events, const AuthSignalConfig& config) { - std::vector signals; - signals.reserve(events.size()); - - for (const auto& event : events) { - const auto mapping = signal_mapping_for_event(event, config); - if (!mapping.has_value()) { - continue; - } - - signals.push_back(AuthSignal{ - event.timestamp, - event.source_ip, - event.username, - mapping->signal_kind, - mapping->counts_as_attempt_evidence, - mapping->counts_as_terminal_auth_failure, - mapping->counts_as_sudo_burst_evidence, - event.line_number}); - } - - return signals; -} - -} // namespace loglens + +} // namespace + +std::vector build_auth_signals(const std::vector& events, const AuthSignalConfig& config) { + std::vector signals; + signals.reserve(events.size()); + + for (const auto& event : events) { + const auto mapping = signal_mapping_for_event(event, config); + if (!mapping.has_value()) { + continue; + } + + signals.push_back(AuthSignal{ + event.timestamp, + event.source_ip, + event.username, + mapping->signal_kind, + mapping->counts_as_attempt_evidence, + mapping->counts_as_terminal_auth_failure, + mapping->counts_as_sudo_burst_evidence, + event.line_number}); + } + + return signals; +} + +} // namespace loglens diff --git a/tests/test_detector.cpp b/tests/test_detector.cpp index 7bcc49f..216392e 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,87 @@ 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 pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.11 user=alice\n"); + + const auto signals = loglens::build_auth_signals(events, loglens::DetectorConfig{}.auth_signal_mappings); + expect(signals.size() == 2, "expected two auth signals"); + + const auto* publickey = find_signal(signals, loglens::AuthSignalKind::SshFailedPublicKey); + expect(publickey != nullptr, "expected publickey signal"); + expect(publickey->counts_as_attempt_evidence, "expected publickey to count as attempt evidence"); + expect(publickey->counts_as_terminal_auth_failure, "expected publickey to count as terminal auth failure"); + + const auto* pam = find_signal(signals, loglens::AuthSignalKind::PamAuthFailure); + expect(pam != nullptr, "expected pam auth signal"); + expect(pam->counts_as_attempt_evidence, "expected pam auth failure to count as attempt evidence"); + expect(!pam->counts_as_terminal_auth_failure, "expected pam auth failure to stay non-terminal by default"); +} + void test_failed_publickey_contributes_to_bruteforce_by_default() { const auto events = build_publickey_bruteforce_candidate_events(); const loglens::Detector detector; const auto findings = detector.analyze(events); - const auto* brute_force = find_finding(findings, loglens::FindingType::BruteForce, "203.0.113.10"); + const auto* brute_force = find_finding(findings, loglens::FindingType::BruteForce, "203.0.113.10"); expect(brute_force != nullptr, "expected publickey evidence to contribute to brute force"); expect(brute_force->event_count == 5, "expected publickey evidence to raise brute force count to five"); } @@ -199,168 +199,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 +369,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..85d957a 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"); } @@ -119,34 +119,34 @@ void test_sudo_event() { 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_failed_publickey_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:27:10 example-host sshd[1243]: Failed publickey for invalid user svc-backup from 203.0.113.40 port 51240 ssh2", + 5); + + expect(event.has_value(), "expected failed publickey event"); + expect(event->username == "svc-backup", "expected parsed publickey username"); + expect(event->source_ip == "203.0.113.40", "expected parsed publickey source ip"); + expect(event->event_type == loglens::EventType::SshFailedPublicKey, "expected ssh publickey type"); +} + +void test_pam_auth_failure_event() { + const auto parser = make_syslog_parser(); + const auto event = parser.parse_line( + "Mar 10 08:28:33 example-host pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.41 user=alice", + 6); + + expect(event.has_value(), "expected pam auth failure event"); + expect(event->program == "pam_unix(sshd:auth)", "expected pam_unix auth program"); + expect(event->username == "alice", "expected pam auth username"); expect(event->source_ip == "203.0.113.41", "expected pam auth source ip"); expect(event->event_type == loglens::EventType::PamAuthFailure, "expected pam auth failure type"); } @@ -169,27 +169,27 @@ 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"); } @@ -305,80 +305,80 @@ 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_journalctl_metadata() { + const loglens::AuthLogParser parser(loglens::ParserConfig{ + loglens::InputMode::JournalctlShortFull, + std::nullopt}); + std::istringstream input( + "Tue 2026-03-10 08:20:10 UTC example-host sshd[2240]: Accepted password for alice from 203.0.113.20 port 51111 ssh2\n" + "bad-line\n"); + + const auto result = parser.parse_stream(input); + expect(result.events.size() == 1, "expected one parsed journalctl event"); + expect(result.warnings.size() == 1, "expected one journalctl warning"); + expect(result.metadata.input_mode == loglens::InputMode::JournalctlShortFull, "expected journalctl metadata mode"); + expect(!result.metadata.assume_year.has_value(), "expected no assumed year for journalctl"); + expect(result.metadata.timezone_present, "expected journalctl timezone metadata"); + expect(result.quality.total_lines == 2, "expected journalctl total line count"); + expect(result.quality.parsed_lines == 1, "expected journalctl parsed line count"); + expect(result.quality.unparsed_lines == 1, "expected journalctl unparsed line count"); + expect(result.quality.parse_success_rate == 0.5, "expected journalctl parse success rate"); + expect(result.quality.top_unknown_patterns.size() == 1, "expected one journalctl unknown pattern"); + expect(result.quality.top_unknown_patterns.front().pattern == "missing_journalctl_short_full_header_fields", + "expected normalized journalctl failure pattern"); +} + void test_syslog_fixture_matrix_file() { const auto parser = make_syslog_parser(); const auto result = parser.parse_file(asset_path("parser_fixture_matrix_syslog.log")); @@ -417,7 +417,7 @@ 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, @@ -454,9 +454,9 @@ 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(); @@ -473,8 +473,8 @@ int main() { test_malformed_line(); test_unknown_auth_patterns_are_warnings_only(); test_stream_warnings_and_metadata(); - test_journalctl_metadata(); - test_syslog_fixture_matrix_file(); - test_journalctl_fixture_matrix_file(); - return 0; -} + test_journalctl_metadata(); + test_syslog_fixture_matrix_file(); + test_journalctl_fixture_matrix_file(); + return 0; +} From 149e53400f404f167dd89613b94c79ed6a7a6e02 Mon Sep 17 00:00:00 2001 From: stacknil Date: Sun, 24 May 2026 00:33:48 +0800 Subject: [PATCH 4/4] docs: clarify LogLens reviewer path --- docs/reviewer-path.md | 102 +++++++++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/docs/reviewer-path.md b/docs/reviewer-path.md index fa3c33f..cd04702 100644 --- a/docs/reviewer-path.md +++ b/docs/reviewer-path.md @@ -1,45 +1,85 @@ -# Reviewer path +# Reviewer Path -LogLens is easiest to review when the first question is explicit. The project is not trying to be a SIEM or a broad Linux auth parser; it is a C++20 offline CLI that makes authentication parsing, detection boundaries, and report artifacts inspectable. +This path is for reviewers who want to understand LogLens quickly without reading the whole repository first. -The core review lens is: +## 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. -That means unsupported and malformed lines should be visible as parser telemetry or warnings instead of disappearing behind confident-looking findings. +## 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` -## First choose the review question +Optional CSV check: -| Review question | Start here | Good stopping point | -| --- | --- | --- | -| What is LogLens? | [`README.md`](../README.md) and [`docs/reviewer-brief.md`](./reviewer-brief.md) | Can state the defensive scope, CLI shape, and non-goals | -| What log formats are supported? | README Detections plus [`docs/parser-contract.md`](./parser-contract.md) | Can name `syslog_legacy` and `journalctl_short_full`, including the explicit year requirement for syslog | -| What artifacts does it produce? | README Run / Sample Output plus [`tests/test_report_contracts.cpp`](../tests/test_report_contracts.cpp) | Can inspect `report.md`, `report.json`, `findings.csv`, and `warnings.csv` | -| Can the parser behavior be trusted? | [`docs/parser-contract.md`](./parser-contract.md), [`tests/test_parser.cpp`](../tests/test_parser.cpp), [`assets/parser_fixture_matrix_syslog.log`](../assets/parser_fixture_matrix_syslog.log), and [`assets/parser_fixture_matrix_journalctl_short_full.log`](../assets/parser_fixture_matrix_journalctl_short_full.log) | Can see known auth lines become events and unknown lines become warnings / telemetry | -| Is it production-ready? | README Known Limitations, [`CHANGELOG.md`](../CHANGELOG.md), and [`docs/release-v0.1.0.md`](./release-v0.1.0.md) | Can state MVP boundaries and what should not be inferred from the findings | +```bash +./build/loglens --mode syslog --year 2026 --csv ./assets/sample_auth.log ./out-csv +``` -## Ten-minute review path +Then inspect: -1. Read the README through Detections and Known Limitations. -2. Skim the reviewer brief for problem, evidence, and boundaries. -3. Run the sample command from the README or reviewer brief. -4. Open `report.md` and `report.json` in the output directory. -5. Compare parser fixture inputs with `docs/parser-contract.md` and `tests/test_parser.cpp` to see recognized and unsupported line handling. +- `out-csv/findings.csv` +- `out-csv/warnings.csv` -## What to look for +Good stopping point: the reviewer can build, test, run a sample, and compare generated artifacts with the report-contract fixtures. -- Parser correctness is treated as a first-class part of the tool, not a hidden precondition. -- Reports include coverage metrics such as parsed, unparsed, warning, and unknown-pattern counts. -- Detection rules are small, centralized, configurable, and threshold-based. -- Unsupported lines remain reviewable through warnings instead of becoming silent misses. -- Public examples use sanitized hosts, users, and documentation IP ranges. +## Boundaries -## Good review outcome +LogLens is intentionally narrow: -A reviewer should be able to say: +- 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 -- LogLens is an offline defensive log-analysis CLI for Linux authentication evidence. -- It supports syslog-style auth logs and `journalctl --output=short-full` style logs. -- It produces deterministic Markdown, JSON, and optional CSV artifacts. -- It makes parser coverage visible before asking the reviewer to trust detection claims. -- It is a focused MVP, not a production SIEM, host correlation engine, or incident verdict system. +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.