Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable user-visible changes should be recorded here.
### Added

- Added sanitized golden `report.md` / `report.json` regression fixtures to lock report contracts.
- Added conservative parser coverage for `Accepted publickey` plus selected `pam_faillock` / `pam_sss` variants.

### Changed

Expand Down
39 changes: 21 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,27 +58,30 @@ LogLens currently detects:
- One IP trying multiple usernames within 15 minutes
- Bursty sudo activity from the same user within 5 minutes

LogLens currently parses and reports these additional auth patterns:

- `Failed publickey` SSH failures, which count toward SSH brute-force detection by default
- `pam_unix(...:auth): authentication failure`
- `pam_unix(...:session): session opened`

LogLens also tracks parser coverage telemetry for unsupported or malformed lines, including:
LogLens currently parses and reports these additional auth patterns:

- `Accepted publickey` SSH successes
- `Failed publickey` SSH failures, which count toward SSH brute-force detection by default
- `pam_unix(...:auth): authentication failure`
- `pam_unix(...:session): session opened`
- selected `pam_faillock(...:auth)` failure variants
- selected `pam_sss(...:auth)` failure variants

LogLens also tracks parser coverage telemetry for unsupported or malformed lines, including:

- `total_lines`
- `parsed_lines`
- `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
LogLens does not currently detect:

- Lateral movement
- MFA abuse
- SSH key misuse
- Many PAM-specific failures beyond the parsed `pam_unix`, `pam_faillock`, and `pam_sss` sample patterns
- Cross-file or cross-host correlation

## Build

Expand Down Expand Up @@ -194,10 +197,10 @@ Tue 2026-03-10 08:31:18 UTC example-host sshd[2245]: Connection closed by authen
## Known Limitations

- `syslog_legacy` requires an explicit year; LogLens does not guess one implicitly.
- `journalctl_short_full` currently supports `UTC`, `GMT`, `Z`, and numeric timezone offsets, not arbitrary timezone abbreviations.
- Parser coverage is intentionally narrow and focused on common `sshd`, `sudo`, and `pam_unix` variants.
- Unsupported lines are surfaced as parser telemetry and warnings, not as detector findings.
- `pam_unix` auth failures remain lower-confidence by default unless signal mappings explicitly upgrade them.
- `journalctl_short_full` currently supports `UTC`, `GMT`, `Z`, and numeric timezone offsets, not arbitrary timezone abbreviations.
- Parser coverage is intentionally narrow and focused on common `sshd`, `sudo`, `pam_unix`, and selected `pam_faillock` / `pam_sss` variants.
- Unsupported lines are surfaced as parser telemetry and warnings, not as detector findings.
- `pam_unix` auth failures remain lower-confidence by default unless signal mappings explicitly upgrade them.
- Detector configuration uses a fixed `config.json` schema rather than partial overrides or alternate config formats.
- Findings are rule-based triage aids, not incident verdicts or attribution.

Expand Down
7 changes: 7 additions & 0 deletions assets/parser_auth_families_journalctl_short_full.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Wed 2026-03-11 10:00:01 UTC example-host sshd[3100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY
Wed 2026-03-11 10:00:42 UTC example-host pam_faillock(sshd:auth): Consecutive login failures for user alice account temporarily locked from 203.0.113.71
Wed 2026-03-11 10:01:13 UTC example-host pam_faillock(sshd:auth): Authentication failure for user bob from 203.0.113.72
Wed 2026-03-11 10:01:54 UTC example-host pam_faillock(sshd:auth): User carol successfully authenticated
Wed 2026-03-11 10:02:25 UTC example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure)
Wed 2026-03-11 10:02:56 UTC example-host pam_sss(sshd:auth): received for user erin: 10 (User not known to the underlying authentication module)
Wed 2026-03-11 10:03:27 UTC example-host pam_sss(sshd:auth): received for user frank: 9 (Authentication service cannot retrieve authentication info)
7 changes: 7 additions & 0 deletions assets/parser_auth_families_syslog.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Mar 11 10:00:01 example-host sshd[2100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY
Mar 11 10:00:42 example-host pam_faillock(sshd:auth): Consecutive login failures for user alice account temporarily locked from 203.0.113.71
Mar 11 10:01:13 example-host pam_faillock(sshd:auth): Authentication failure for user bob from 203.0.113.72
Mar 11 10:01:54 example-host pam_faillock(sshd:auth): User carol successfully authenticated
Mar 11 10:02:25 example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure)
Mar 11 10:02:56 example-host pam_sss(sshd:auth): received for user erin: 10 (User not known to the underlying authentication module)
Mar 11 10:03:27 example-host pam_sss(sshd:auth): received for user frank: 9 (Authentication service cannot retrieve authentication info)
3 changes: 3 additions & 0 deletions src/event.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum class EventType {
Unknown,
SshFailedPassword,
SshAcceptedPassword,
SshAcceptedPublicKey,
SshInvalidUser,
SshFailedPublicKey,
PamAuthFailure,
Expand All @@ -37,6 +38,8 @@ inline std::string to_string(EventType type) {
return "ssh_failed_password";
case EventType::SshAcceptedPassword:
return "ssh_accepted_password";
case EventType::SshAcceptedPublicKey:
return "ssh_accepted_publickey";
case EventType::SshInvalidUser:
return "ssh_invalid_user";
case EventType::SshFailedPublicKey:
Expand Down
118 changes: 118 additions & 0 deletions src/parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,24 @@ bool parse_ssh_accepted_message(std::string_view message, Event& event) {
return true;
}

bool parse_ssh_accepted_publickey_message(std::string_view message, Event& event) {
static constexpr std::string_view accepted_prefix = "Accepted publickey for ";
if (!message.starts_with(accepted_prefix)) {
return false;
}

auto remaining = message.substr(accepted_prefix.size());
const auto username = consume_token(remaining);
if (username.empty()) {
return false;
}

event.username.assign(username);
event.source_ip = extract_token_after(message, " from ");
event.event_type = EventType::SshAcceptedPublicKey;
return true;
}

bool parse_ssh_failed_publickey_message(std::string_view message, Event& event) {
static constexpr std::string_view publickey_prefix = "Failed publickey for ";
if (!message.starts_with(publickey_prefix)) {
Expand Down Expand Up @@ -367,6 +385,25 @@ bool parse_ssh_invalid_user_message(std::string_view message, Event& event) {
return true;
}

bool parse_pam_named_user_failure_message(std::string_view message,
std::string_view prefix,
Event& event) {
if (!message.starts_with(prefix)) {
return false;
}

auto remaining = message.substr(prefix.size());
const auto username = consume_token(remaining);
if (username.empty()) {
return false;
}

event.username.assign(username);
event.source_ip = extract_token_after(message, " from ");
event.event_type = EventType::PamAuthFailure;
return true;
}

bool parse_pam_auth_failure_message(std::string_view message, Event& event) {
static constexpr std::string_view auth_failure_prefix = "authentication failure;";
if (!message.starts_with(auth_failure_prefix)) {
Expand All @@ -379,6 +416,30 @@ bool parse_pam_auth_failure_message(std::string_view message, Event& event) {
return true;
}

bool parse_pam_sss_received_failure_message(std::string_view message, Event& event) {
static constexpr std::string_view received_prefix = "received for user ";
static constexpr std::string_view failure_marker = "(Authentication failure)";

if (!message.starts_with(received_prefix) || message.find(failure_marker) == std::string_view::npos) {
return false;
}

auto remaining = message.substr(received_prefix.size());
const auto separator = remaining.find(':');
if (separator == std::string_view::npos) {
return false;
}

const auto username = trim(remaining.substr(0, separator));
if (username.empty()) {
return false;
}

event.username.assign(username);
event.event_type = EventType::PamAuthFailure;
return true;
}

bool parse_session_opened_message(std::string_view message, Event& event) {
static constexpr std::string_view session_prefix = "session opened for user ";
if (!message.starts_with(session_prefix)) {
Expand Down Expand Up @@ -423,6 +484,38 @@ bool parse_sudo_message(std::string_view message, Event& event) {
return true;
}

bool parse_pam_faillock_message(std::string_view message, Event& event) {
if (parse_pam_named_user_failure_message(message, "Consecutive login failures for user ", event)) {
return true;
}

if (parse_pam_named_user_failure_message(message, "Authentication failure for user ", event)) {
return true;
}

return false;
}

std::string classify_unknown_pam_faillock_pattern(std::string_view message) {
if (message.starts_with("User ") && message.find("successfully authenticated") != std::string_view::npos) {
return "pam_faillock_authsucc";
}

return "pam_faillock_other";
}

std::string classify_unknown_pam_sss_pattern(std::string_view message) {
if (message.find("User not known to the underlying authentication module") != std::string_view::npos) {
return "pam_sss_unknown_user";
}

if (message.find("Authentication service cannot retrieve authentication info") != std::string_view::npos) {
return "pam_sss_authinfo_unavail";
}

return "pam_sss_other";
}

std::string classify_unknown_auth_pattern(const Event& event) {
const auto message = std::string_view{event.message};
if (event.program == "sshd") {
Expand All @@ -444,6 +537,14 @@ std::string classify_unknown_auth_pattern(const Event& event) {
return "pam_unix_other";
}

if (event.program.starts_with("pam_faillock(")) {
return classify_unknown_pam_faillock_pattern(message);
}

if (event.program.starts_with("pam_sss(")) {
return classify_unknown_pam_sss_pattern(message);
}

if (event.program == "sudo") {
return "sudo_other";
}
Expand All @@ -460,6 +561,9 @@ bool classify_event(Event& event) {
if (parse_ssh_accepted_message(message, event)) {
return true;
}
if (parse_ssh_accepted_publickey_message(message, event)) {
return true;
}
if (parse_ssh_failed_publickey_message(message, event)) {
return true;
}
Expand All @@ -479,6 +583,20 @@ bool classify_event(Event& event) {
return false;
}

if (event.program.starts_with("pam_faillock(")) {
return parse_pam_faillock_message(message, event);
}

if (event.program.starts_with("pam_sss(")) {
if (parse_pam_auth_failure_message(message, event)) {
return true;
}
if (parse_pam_sss_received_failure_message(message, event)) {
return true;
}
return false;
}

if (event.program == "sudo") {
return parse_sudo_message(message, event);
}
Expand Down
1 change: 1 addition & 0 deletions src/report.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ std::vector<std::pair<EventType, std::size_t>> build_event_counts(const std::vec
std::vector<std::pair<EventType, std::size_t>> counts = {
{EventType::SshFailedPassword, 0},
{EventType::SshAcceptedPassword, 0},
{EventType::SshAcceptedPublicKey, 0},
{EventType::SshInvalidUser, 0},
{EventType::SshFailedPublicKey, 0},
{EventType::PamAuthFailure, 0},
Expand Down
Loading
Loading