Skip to content

Commit 1d3979b

Browse files
authored
feat: add parser support for accepted publickey, pam_faillock, and pam_sss variants (#15)
1 parent a2ffeaf commit 1d3979b

10 files changed

Lines changed: 956 additions & 680 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ All notable user-visible changes should be recorded here.
77
### Added
88

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

1112
### Changed
1213

README.md

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,27 +58,30 @@ LogLens currently detects:
5858
- One IP trying multiple usernames within 15 minutes
5959
- Bursty sudo activity from the same user within 5 minutes
6060

61-
LogLens currently parses and reports these additional auth patterns:
62-
63-
- `Failed publickey` SSH failures, which count toward SSH brute-force detection by default
64-
- `pam_unix(...:auth): authentication failure`
65-
- `pam_unix(...:session): session opened`
66-
67-
LogLens also tracks parser coverage telemetry for unsupported or malformed lines, including:
61+
LogLens currently parses and reports these additional auth patterns:
62+
63+
- `Accepted publickey` SSH successes
64+
- `Failed publickey` SSH failures, which count toward SSH brute-force detection by default
65+
- `pam_unix(...:auth): authentication failure`
66+
- `pam_unix(...:session): session opened`
67+
- selected `pam_faillock(...:auth)` failure variants
68+
- selected `pam_sss(...:auth)` failure variants
69+
70+
LogLens also tracks parser coverage telemetry for unsupported or malformed lines, including:
6871

6972
- `total_lines`
7073
- `parsed_lines`
7174
- `unparsed_lines`
7275
- `parse_success_rate`
7376
- `top_unknown_patterns`
7477

75-
LogLens does not currently detect:
76-
77-
- Lateral movement
78-
- MFA abuse
79-
- SSH key misuse
80-
- PAM-specific failures beyond the parsed sample patterns
81-
- Cross-file or cross-host correlation
78+
LogLens does not currently detect:
79+
80+
- Lateral movement
81+
- MFA abuse
82+
- SSH key misuse
83+
- Many PAM-specific failures beyond the parsed `pam_unix`, `pam_faillock`, and `pam_sss` sample patterns
84+
- Cross-file or cross-host correlation
8285

8386
## Build
8487

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

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
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
2+
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
3+
Wed 2026-03-11 10:01:13 UTC example-host pam_faillock(sshd:auth): Authentication failure for user bob from 203.0.113.72
4+
Wed 2026-03-11 10:01:54 UTC example-host pam_faillock(sshd:auth): User carol successfully authenticated
5+
Wed 2026-03-11 10:02:25 UTC example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure)
6+
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)
7+
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)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Mar 11 10:00:01 example-host sshd[2100]: Accepted publickey for alice from 203.0.113.70 port 53000 ssh2: ED25519 SHA256:SANITIZEDKEY
2+
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
3+
Mar 11 10:01:13 example-host pam_faillock(sshd:auth): Authentication failure for user bob from 203.0.113.72
4+
Mar 11 10:01:54 example-host pam_faillock(sshd:auth): User carol successfully authenticated
5+
Mar 11 10:02:25 example-host pam_sss(sshd:auth): received for user dave: 7 (Authentication failure)
6+
Mar 11 10:02:56 example-host pam_sss(sshd:auth): received for user erin: 10 (User not known to the underlying authentication module)
7+
Mar 11 10:03:27 example-host pam_sss(sshd:auth): received for user frank: 9 (Authentication service cannot retrieve authentication info)

src/event.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ enum class EventType {
1212
Unknown,
1313
SshFailedPassword,
1414
SshAcceptedPassword,
15+
SshAcceptedPublicKey,
1516
SshInvalidUser,
1617
SshFailedPublicKey,
1718
PamAuthFailure,
@@ -37,6 +38,8 @@ inline std::string to_string(EventType type) {
3738
return "ssh_failed_password";
3839
case EventType::SshAcceptedPassword:
3940
return "ssh_accepted_password";
41+
case EventType::SshAcceptedPublicKey:
42+
return "ssh_accepted_publickey";
4043
case EventType::SshInvalidUser:
4144
return "ssh_invalid_user";
4245
case EventType::SshFailedPublicKey:

src/parser.cpp

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,24 @@ bool parse_ssh_accepted_message(std::string_view message, Event& event) {
327327
return true;
328328
}
329329

330+
bool parse_ssh_accepted_publickey_message(std::string_view message, Event& event) {
331+
static constexpr std::string_view accepted_prefix = "Accepted publickey for ";
332+
if (!message.starts_with(accepted_prefix)) {
333+
return false;
334+
}
335+
336+
auto remaining = message.substr(accepted_prefix.size());
337+
const auto username = consume_token(remaining);
338+
if (username.empty()) {
339+
return false;
340+
}
341+
342+
event.username.assign(username);
343+
event.source_ip = extract_token_after(message, " from ");
344+
event.event_type = EventType::SshAcceptedPublicKey;
345+
return true;
346+
}
347+
330348
bool parse_ssh_failed_publickey_message(std::string_view message, Event& event) {
331349
static constexpr std::string_view publickey_prefix = "Failed publickey for ";
332350
if (!message.starts_with(publickey_prefix)) {
@@ -367,6 +385,25 @@ bool parse_ssh_invalid_user_message(std::string_view message, Event& event) {
367385
return true;
368386
}
369387

388+
bool parse_pam_named_user_failure_message(std::string_view message,
389+
std::string_view prefix,
390+
Event& event) {
391+
if (!message.starts_with(prefix)) {
392+
return false;
393+
}
394+
395+
auto remaining = message.substr(prefix.size());
396+
const auto username = consume_token(remaining);
397+
if (username.empty()) {
398+
return false;
399+
}
400+
401+
event.username.assign(username);
402+
event.source_ip = extract_token_after(message, " from ");
403+
event.event_type = EventType::PamAuthFailure;
404+
return true;
405+
}
406+
370407
bool parse_pam_auth_failure_message(std::string_view message, Event& event) {
371408
static constexpr std::string_view auth_failure_prefix = "authentication failure;";
372409
if (!message.starts_with(auth_failure_prefix)) {
@@ -379,6 +416,30 @@ bool parse_pam_auth_failure_message(std::string_view message, Event& event) {
379416
return true;
380417
}
381418

419+
bool parse_pam_sss_received_failure_message(std::string_view message, Event& event) {
420+
static constexpr std::string_view received_prefix = "received for user ";
421+
static constexpr std::string_view failure_marker = "(Authentication failure)";
422+
423+
if (!message.starts_with(received_prefix) || message.find(failure_marker) == std::string_view::npos) {
424+
return false;
425+
}
426+
427+
auto remaining = message.substr(received_prefix.size());
428+
const auto separator = remaining.find(':');
429+
if (separator == std::string_view::npos) {
430+
return false;
431+
}
432+
433+
const auto username = trim(remaining.substr(0, separator));
434+
if (username.empty()) {
435+
return false;
436+
}
437+
438+
event.username.assign(username);
439+
event.event_type = EventType::PamAuthFailure;
440+
return true;
441+
}
442+
382443
bool parse_session_opened_message(std::string_view message, Event& event) {
383444
static constexpr std::string_view session_prefix = "session opened for user ";
384445
if (!message.starts_with(session_prefix)) {
@@ -423,6 +484,38 @@ bool parse_sudo_message(std::string_view message, Event& event) {
423484
return true;
424485
}
425486

487+
bool parse_pam_faillock_message(std::string_view message, Event& event) {
488+
if (parse_pam_named_user_failure_message(message, "Consecutive login failures for user ", event)) {
489+
return true;
490+
}
491+
492+
if (parse_pam_named_user_failure_message(message, "Authentication failure for user ", event)) {
493+
return true;
494+
}
495+
496+
return false;
497+
}
498+
499+
std::string classify_unknown_pam_faillock_pattern(std::string_view message) {
500+
if (message.starts_with("User ") && message.find("successfully authenticated") != std::string_view::npos) {
501+
return "pam_faillock_authsucc";
502+
}
503+
504+
return "pam_faillock_other";
505+
}
506+
507+
std::string classify_unknown_pam_sss_pattern(std::string_view message) {
508+
if (message.find("User not known to the underlying authentication module") != std::string_view::npos) {
509+
return "pam_sss_unknown_user";
510+
}
511+
512+
if (message.find("Authentication service cannot retrieve authentication info") != std::string_view::npos) {
513+
return "pam_sss_authinfo_unavail";
514+
}
515+
516+
return "pam_sss_other";
517+
}
518+
426519
std::string classify_unknown_auth_pattern(const Event& event) {
427520
const auto message = std::string_view{event.message};
428521
if (event.program == "sshd") {
@@ -444,6 +537,14 @@ std::string classify_unknown_auth_pattern(const Event& event) {
444537
return "pam_unix_other";
445538
}
446539

540+
if (event.program.starts_with("pam_faillock(")) {
541+
return classify_unknown_pam_faillock_pattern(message);
542+
}
543+
544+
if (event.program.starts_with("pam_sss(")) {
545+
return classify_unknown_pam_sss_pattern(message);
546+
}
547+
447548
if (event.program == "sudo") {
448549
return "sudo_other";
449550
}
@@ -460,6 +561,9 @@ bool classify_event(Event& event) {
460561
if (parse_ssh_accepted_message(message, event)) {
461562
return true;
462563
}
564+
if (parse_ssh_accepted_publickey_message(message, event)) {
565+
return true;
566+
}
463567
if (parse_ssh_failed_publickey_message(message, event)) {
464568
return true;
465569
}
@@ -479,6 +583,20 @@ bool classify_event(Event& event) {
479583
return false;
480584
}
481585

586+
if (event.program.starts_with("pam_faillock(")) {
587+
return parse_pam_faillock_message(message, event);
588+
}
589+
590+
if (event.program.starts_with("pam_sss(")) {
591+
if (parse_pam_auth_failure_message(message, event)) {
592+
return true;
593+
}
594+
if (parse_pam_sss_received_failure_message(message, event)) {
595+
return true;
596+
}
597+
return false;
598+
}
599+
482600
if (event.program == "sudo") {
483601
return parse_sudo_message(message, event);
484602
}

src/report.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ std::vector<std::pair<EventType, std::size_t>> build_event_counts(const std::vec
7171
std::vector<std::pair<EventType, std::size_t>> counts = {
7272
{EventType::SshFailedPassword, 0},
7373
{EventType::SshAcceptedPassword, 0},
74+
{EventType::SshAcceptedPublicKey, 0},
7475
{EventType::SshInvalidUser, 0},
7576
{EventType::SshFailedPublicKey, 0},
7677
{EventType::PamAuthFailure, 0},

0 commit comments

Comments
 (0)