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
12 changes: 12 additions & 0 deletions assets/parser_fixture_matrix_journalctl_short_full.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Tue 2026-03-10 09:00:01 UTC example-host sshd[3000]: Failed password for invalid user admin from 203.0.113.10 port 52000 ssh2
Tue 2026-03-10 09:00:40 UTC example-host sshd[3001]: Failed publickey for alice from 203.0.113.11 port 52001 ssh2
Tue 2026-03-10 09:01:15 UTC example-host sshd[3002]: Invalid user backup from 203.0.113.12 port 52002
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: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:06:10 UTC example-host pam_unix(sshd:session): session closed for user alice
12 changes: 12 additions & 0 deletions assets/parser_fixture_matrix_syslog.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Mar 10 09:00:01 example-host sshd[2000]: Failed password for invalid user admin from 203.0.113.10 port 52000 ssh2
Mar 10 09:00:40 example-host sshd[2001]: Failed publickey for alice from 203.0.113.11 port 52001 ssh2
Mar 10 09:01:15 example-host sshd[2002]: Invalid user backup from 203.0.113.12 port 52002
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: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:06:10 example-host pam_unix(sshd:session): session closed for user alice
30 changes: 9 additions & 21 deletions src/detector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ namespace loglens {
namespace {

using SignalGroup = std::unordered_map<std::string, std::vector<const AuthSignal*>>;
using EventGroup = std::unordered_map<std::string, std::vector<const Event*>>;

std::vector<const AuthSignal*> sort_signals_by_time(const std::vector<const AuthSignal*>& signals) {
auto sorted = signals;
Expand All @@ -20,17 +19,6 @@ std::vector<const AuthSignal*> sort_signals_by_time(const std::vector<const Auth
return sorted;
}

std::vector<const Event*> sort_events_by_time(const std::vector<const Event*>& events) {
auto sorted = events;
std::sort(sorted.begin(), sorted.end(), [](const Event* left, const Event* right) {
if (left->timestamp != right->timestamp) {
return left->timestamp < right->timestamp;
}
return left->line_number < right->line_number;
});
return sorted;
}

SignalGroup group_terminal_auth_failures_by_ip(const std::vector<AuthSignal>& signals) {
SignalGroup grouped;
for (const auto& signal : signals) {
Expand All @@ -53,13 +41,13 @@ SignalGroup group_attempt_evidence_by_ip(const std::vector<AuthSignal>& signals)
return grouped;
}

EventGroup group_sudo_by_user(const std::vector<Event>& events) {
EventGroup grouped;
for (const auto& event : events) {
if (event.username.empty() || event.event_type != EventType::SudoCommand) {
SignalGroup group_sudo_burst_evidence_by_user(const std::vector<AuthSignal>& signals) {
SignalGroup grouped;
for (const auto& signal : signals) {
if (signal.username.empty() || !signal.counts_as_sudo_burst_evidence) {
continue;
}
grouped[event.username].push_back(&event);
grouped[signal.username].push_back(&signal);
}
return grouped;
}
Expand Down Expand Up @@ -220,12 +208,12 @@ std::vector<Finding> detect_multi_user(const std::vector<AuthSignal>& signals, c
return findings;
}

std::vector<Finding> detect_sudo_burst(const std::vector<Event>& events, const DetectorConfig& config) {
std::vector<Finding> detect_sudo_burst(const std::vector<AuthSignal>& signals, const DetectorConfig& config) {
std::vector<Finding> findings;
const auto grouped = group_sudo_by_user(events);
const auto grouped = group_sudo_burst_evidence_by_user(signals);

for (const auto& [username, group] : grouped) {
const auto ordered = sort_events_by_time(group);
const auto ordered = sort_signals_by_time(group);
std::size_t start = 0;
std::size_t best_count = 0;
std::size_t best_start = 0;
Expand Down Expand Up @@ -279,7 +267,7 @@ std::vector<Finding> Detector::analyze(const std::vector<Event>& events) const {
const auto auth_signals = build_auth_signals(events, config_.auth_signal_mappings);
auto findings = detect_brute_force(auth_signals, config_);
auto multi_user = detect_multi_user(auth_signals, config_);
auto sudo = detect_sudo_burst(events, config_);
auto sudo = detect_sudo_burst(auth_signals, config_);

findings.insert(findings.end(), multi_user.begin(), multi_user.end());
findings.insert(findings.end(), sudo.begin(), sudo.end());
Expand Down
84 changes: 52 additions & 32 deletions src/signal.cpp
Original file line number Diff line number Diff line change
@@ -1,43 +1,62 @@
#include "signal.hpp"

#include <optional>

namespace loglens {
namespace {

AuthSignalKind signal_kind_for_event_type(EventType type) {
switch (type) {
case EventType::SshFailedPassword:
return AuthSignalKind::SshFailedPassword;
case EventType::SshInvalidUser:
return AuthSignalKind::SshInvalidUser;
case EventType::SshFailedPublicKey:
return AuthSignalKind::SshFailedPublicKey;
case EventType::PamAuthFailure:
return AuthSignalKind::PamAuthFailure;
case EventType::Unknown:
case EventType::SshAcceptedPassword:
case EventType::SessionOpened:
case EventType::SudoCommand:
default:
return AuthSignalKind::Unknown;
}
}
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;
};

const AuthSignalBehavior* behavior_for_event_type(EventType type, const AuthSignalConfig& config) {
switch (type) {
std::optional<SignalMapping> signal_mapping_for_event(const Event& event, const AuthSignalConfig& config) {
switch (event.event_type) {
case EventType::SshFailedPassword:
return &config.ssh_failed_password;
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 &config.ssh_invalid_user;
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 &config.ssh_failed_publickey;
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 &config.pam_auth_failure;
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::SessionOpened:
case EventType::SudoCommand:
default:
return nullptr;
return std::nullopt;
}
}

Expand All @@ -48,18 +67,19 @@ std::vector<AuthSignal> build_auth_signals(const std::vector<Event>& events, con
signals.reserve(events.size());

for (const auto& event : events) {
const auto* behavior = behavior_for_event_type(event.event_type, config);
if (behavior == nullptr) {
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,
signal_kind_for_event_type(event.event_type),
behavior->counts_as_attempt_evidence,
behavior->counts_as_terminal_auth_failure,
mapping->signal_kind,
mapping->counts_as_attempt_evidence,
mapping->counts_as_terminal_auth_failure,
mapping->counts_as_sudo_burst_evidence,
event.line_number});
}

Expand Down
5 changes: 4 additions & 1 deletion src/signal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ enum class AuthSignalKind {
SshFailedPassword,
SshInvalidUser,
SshFailedPublicKey,
PamAuthFailure
PamAuthFailure,
SudoCommand,
SudoSessionOpened
};

struct AuthSignalBehavior {
Expand All @@ -35,6 +37,7 @@ struct AuthSignal {
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::size_t line_number = 0;
};

Expand Down
82 changes: 82 additions & 0 deletions tests/test_detector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ const loglens::AuthSignal* find_signal(const std::vector<loglens::AuthSignal>& s
return it == signals.end() ? nullptr : &(*it);
}

std::size_t count_signals(const std::vector<loglens::AuthSignal>& signals,
loglens::AuthSignalKind signal_kind) {
return static_cast<std::size_t>(std::count_if(signals.begin(), signals.end(), [&](const loglens::AuthSignal& signal) {
return signal.signal_kind == signal_kind;
}));
}

std::vector<loglens::Event> parse_events(loglens::ParserConfig config, std::string_view input_text) {
const loglens::AuthLogParser parser(config);
std::istringstream input(std::string{input_text});
Expand Down Expand Up @@ -87,6 +94,23 @@ std::vector<loglens::Event> build_pam_bruteforce_candidate_events() {
"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<loglens::Event> 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<loglens::Event> 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;
Expand Down Expand Up @@ -149,6 +173,61 @@ 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_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;
Expand Down Expand Up @@ -264,6 +343,9 @@ int main() {
test_custom_thresholds();
test_auth_signal_defaults();
test_failed_publickey_contributes_to_bruteforce_by_default();
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();
Expand Down
Loading
Loading