Skip to content

Commit 68554bd

Browse files
authored
Merge pull request #8 from stacknil/codex/feat/parser-fixture-matrix-v0.2
feat: expand parser fixture matrix and unify sudo signal semantics
2 parents 472fe68 + fb8c5c7 commit 68554bd

File tree

7 files changed

+271
-54
lines changed

7 files changed

+271
-54
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
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
2+
Tue 2026-03-10 09:00:40 UTC example-host sshd[3001]: Failed publickey for alice from 203.0.113.11 port 52001 ssh2
3+
Tue 2026-03-10 09:01:15 UTC example-host sshd[3002]: Invalid user backup from 203.0.113.12 port 52002
4+
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
5+
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)
6+
Tue 2026-03-10 09:03:05 UTC example-host pam_unix(su-l:session): session opened for user root by bob(uid=1001)
7+
Tue 2026-03-10 09:03:40 UTC example-host sshd[3003]: Connection closed by user alice 203.0.113.50 port 52010 [preauth]
8+
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]
9+
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]
10+
Tue 2026-03-10 09:05:02 UTC example-host sshd[3006]: Disconnected from authenticating user dave 203.0.113.53 port 52013 [preauth]
11+
Tue 2026-03-10 09:05:34 UTC example-host sshd[3007]: Timeout, client not responding from 203.0.113.54 port 52014
12+
Tue 2026-03-10 09:06:10 UTC example-host pam_unix(sshd:session): session closed for user alice
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Mar 10 09:00:01 example-host sshd[2000]: Failed password for invalid user admin from 203.0.113.10 port 52000 ssh2
2+
Mar 10 09:00:40 example-host sshd[2001]: Failed publickey for alice from 203.0.113.11 port 52001 ssh2
3+
Mar 10 09:01:15 example-host sshd[2002]: Invalid user backup from 203.0.113.12 port 52002
4+
Mar 10 09:01:52 example-host pam_unix(sshd:auth): authentication failure; user=alice euid=0 tty=ssh rhost=203.0.113.40
5+
Mar 10 09:02:30 example-host pam_unix(sudo:session): session opened for user root(uid=0) by alice(uid=1000)
6+
Mar 10 09:03:05 example-host pam_unix(su-l:session): session opened for user root by bob(uid=1001)
7+
Mar 10 09:03:40 example-host sshd[2003]: Connection closed by user alice 203.0.113.50 port 52010 [preauth]
8+
Mar 10 09:04:05 example-host sshd[2004]: Connection closed by authenticating user carol 203.0.113.51 port 52011 [preauth]
9+
Mar 10 09:04:28 example-host sshd[2005]: Connection closed by invalid user deploy 203.0.113.52 port 52012 [preauth]
10+
Mar 10 09:05:02 example-host sshd[2006]: Disconnected from authenticating user dave 203.0.113.53 port 52013 [preauth]
11+
Mar 10 09:05:34 example-host sshd[2007]: Timeout, client not responding from 203.0.113.54 port 52014
12+
Mar 10 09:06:10 example-host pam_unix(sshd:session): session closed for user alice

src/detector.cpp

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ namespace loglens {
77
namespace {
88

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

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

23-
std::vector<const Event*> sort_events_by_time(const std::vector<const Event*>& events) {
24-
auto sorted = events;
25-
std::sort(sorted.begin(), sorted.end(), [](const Event* left, const Event* right) {
26-
if (left->timestamp != right->timestamp) {
27-
return left->timestamp < right->timestamp;
28-
}
29-
return left->line_number < right->line_number;
30-
});
31-
return sorted;
32-
}
33-
3422
SignalGroup group_terminal_auth_failures_by_ip(const std::vector<AuthSignal>& signals) {
3523
SignalGroup grouped;
3624
for (const auto& signal : signals) {
@@ -53,13 +41,13 @@ SignalGroup group_attempt_evidence_by_ip(const std::vector<AuthSignal>& signals)
5341
return grouped;
5442
}
5543

56-
EventGroup group_sudo_by_user(const std::vector<Event>& events) {
57-
EventGroup grouped;
58-
for (const auto& event : events) {
59-
if (event.username.empty() || event.event_type != EventType::SudoCommand) {
44+
SignalGroup group_sudo_burst_evidence_by_user(const std::vector<AuthSignal>& signals) {
45+
SignalGroup grouped;
46+
for (const auto& signal : signals) {
47+
if (signal.username.empty() || !signal.counts_as_sudo_burst_evidence) {
6048
continue;
6149
}
62-
grouped[event.username].push_back(&event);
50+
grouped[signal.username].push_back(&signal);
6351
}
6452
return grouped;
6553
}
@@ -220,12 +208,12 @@ std::vector<Finding> detect_multi_user(const std::vector<AuthSignal>& signals, c
220208
return findings;
221209
}
222210

223-
std::vector<Finding> detect_sudo_burst(const std::vector<Event>& events, const DetectorConfig& config) {
211+
std::vector<Finding> detect_sudo_burst(const std::vector<AuthSignal>& signals, const DetectorConfig& config) {
224212
std::vector<Finding> findings;
225-
const auto grouped = group_sudo_by_user(events);
213+
const auto grouped = group_sudo_burst_evidence_by_user(signals);
226214

227215
for (const auto& [username, group] : grouped) {
228-
const auto ordered = sort_events_by_time(group);
216+
const auto ordered = sort_signals_by_time(group);
229217
std::size_t start = 0;
230218
std::size_t best_count = 0;
231219
std::size_t best_start = 0;
@@ -279,7 +267,7 @@ std::vector<Finding> Detector::analyze(const std::vector<Event>& events) const {
279267
const auto auth_signals = build_auth_signals(events, config_.auth_signal_mappings);
280268
auto findings = detect_brute_force(auth_signals, config_);
281269
auto multi_user = detect_multi_user(auth_signals, config_);
282-
auto sudo = detect_sudo_burst(events, config_);
270+
auto sudo = detect_sudo_burst(auth_signals, config_);
283271

284272
findings.insert(findings.end(), multi_user.begin(), multi_user.end());
285273
findings.insert(findings.end(), sudo.begin(), sudo.end());

src/signal.cpp

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,62 @@
11
#include "signal.hpp"
22

3+
#include <optional>
4+
35
namespace loglens {
46
namespace {
57

6-
AuthSignalKind signal_kind_for_event_type(EventType type) {
7-
switch (type) {
8-
case EventType::SshFailedPassword:
9-
return AuthSignalKind::SshFailedPassword;
10-
case EventType::SshInvalidUser:
11-
return AuthSignalKind::SshInvalidUser;
12-
case EventType::SshFailedPublicKey:
13-
return AuthSignalKind::SshFailedPublicKey;
14-
case EventType::PamAuthFailure:
15-
return AuthSignalKind::PamAuthFailure;
16-
case EventType::Unknown:
17-
case EventType::SshAcceptedPassword:
18-
case EventType::SessionOpened:
19-
case EventType::SudoCommand:
20-
default:
21-
return AuthSignalKind::Unknown;
22-
}
23-
}
8+
struct SignalMapping {
9+
AuthSignalKind signal_kind = AuthSignalKind::Unknown;
10+
bool counts_as_attempt_evidence = false;
11+
bool counts_as_terminal_auth_failure = false;
12+
bool counts_as_sudo_burst_evidence = false;
13+
};
2414

25-
const AuthSignalBehavior* behavior_for_event_type(EventType type, const AuthSignalConfig& config) {
26-
switch (type) {
15+
std::optional<SignalMapping> signal_mapping_for_event(const Event& event, const AuthSignalConfig& config) {
16+
switch (event.event_type) {
2717
case EventType::SshFailedPassword:
28-
return &config.ssh_failed_password;
18+
return SignalMapping{
19+
AuthSignalKind::SshFailedPassword,
20+
config.ssh_failed_password.counts_as_attempt_evidence,
21+
config.ssh_failed_password.counts_as_terminal_auth_failure,
22+
false};
2923
case EventType::SshInvalidUser:
30-
return &config.ssh_invalid_user;
24+
return SignalMapping{
25+
AuthSignalKind::SshInvalidUser,
26+
config.ssh_invalid_user.counts_as_attempt_evidence,
27+
config.ssh_invalid_user.counts_as_terminal_auth_failure,
28+
false};
3129
case EventType::SshFailedPublicKey:
32-
return &config.ssh_failed_publickey;
30+
return SignalMapping{
31+
AuthSignalKind::SshFailedPublicKey,
32+
config.ssh_failed_publickey.counts_as_attempt_evidence,
33+
config.ssh_failed_publickey.counts_as_terminal_auth_failure,
34+
false};
3335
case EventType::PamAuthFailure:
34-
return &config.pam_auth_failure;
36+
return SignalMapping{
37+
AuthSignalKind::PamAuthFailure,
38+
config.pam_auth_failure.counts_as_attempt_evidence,
39+
config.pam_auth_failure.counts_as_terminal_auth_failure,
40+
false};
41+
case EventType::SudoCommand:
42+
return SignalMapping{
43+
AuthSignalKind::SudoCommand,
44+
false,
45+
false,
46+
true};
47+
case EventType::SessionOpened:
48+
if (event.program == "pam_unix(sudo:session)") {
49+
return SignalMapping{
50+
AuthSignalKind::SudoSessionOpened,
51+
false,
52+
false,
53+
false};
54+
}
55+
return std::nullopt;
3556
case EventType::Unknown:
3657
case EventType::SshAcceptedPassword:
37-
case EventType::SessionOpened:
38-
case EventType::SudoCommand:
3958
default:
40-
return nullptr;
59+
return std::nullopt;
4160
}
4261
}
4362

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

5069
for (const auto& event : events) {
51-
const auto* behavior = behavior_for_event_type(event.event_type, config);
52-
if (behavior == nullptr) {
70+
const auto mapping = signal_mapping_for_event(event, config);
71+
if (!mapping.has_value()) {
5372
continue;
5473
}
5574

5675
signals.push_back(AuthSignal{
5776
event.timestamp,
5877
event.source_ip,
5978
event.username,
60-
signal_kind_for_event_type(event.event_type),
61-
behavior->counts_as_attempt_evidence,
62-
behavior->counts_as_terminal_auth_failure,
79+
mapping->signal_kind,
80+
mapping->counts_as_attempt_evidence,
81+
mapping->counts_as_terminal_auth_failure,
82+
mapping->counts_as_sudo_burst_evidence,
6383
event.line_number});
6484
}
6585

src/signal.hpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ enum class AuthSignalKind {
1313
SshFailedPassword,
1414
SshInvalidUser,
1515
SshFailedPublicKey,
16-
PamAuthFailure
16+
PamAuthFailure,
17+
SudoCommand,
18+
SudoSessionOpened
1719
};
1820

1921
struct AuthSignalBehavior {
@@ -35,6 +37,7 @@ struct AuthSignal {
3537
AuthSignalKind signal_kind = AuthSignalKind::Unknown;
3638
bool counts_as_attempt_evidence = false;
3739
bool counts_as_terminal_auth_failure = false;
40+
bool counts_as_sudo_burst_evidence = false;
3841
std::size_t line_number = 0;
3942
};
4043

tests/test_detector.cpp

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const loglens::AuthSignal* find_signal(const std::vector<loglens::AuthSignal>& s
3636
return it == signals.end() ? nullptr : &(*it);
3737
}
3838

39+
std::size_t count_signals(const std::vector<loglens::AuthSignal>& signals,
40+
loglens::AuthSignalKind signal_kind) {
41+
return static_cast<std::size_t>(std::count_if(signals.begin(), signals.end(), [&](const loglens::AuthSignal& signal) {
42+
return signal.signal_kind == signal_kind;
43+
}));
44+
}
45+
3946
std::vector<loglens::Event> parse_events(loglens::ParserConfig config, std::string_view input_text) {
4047
const loglens::AuthLogParser parser(config);
4148
std::istringstream input(std::string{input_text});
@@ -87,6 +94,23 @@ std::vector<loglens::Event> build_pam_bruteforce_candidate_events() {
8794
"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");
8895
}
8996

97+
std::vector<loglens::Event> build_sudo_signal_candidate_events() {
98+
return parse_events(
99+
make_syslog_config(),
100+
"Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh\n"
101+
"Mar 10 08:21:05 example-host pam_unix(sudo:session): session opened for user root by alice(uid=0)\n"
102+
"Mar 10 08:21:10 example-host pam_unix(sshd:session): session closed for user alice\n");
103+
}
104+
105+
std::vector<loglens::Event> build_sudo_burst_preservation_events() {
106+
return parse_events(
107+
make_syslog_config(),
108+
"Mar 10 08:21:00 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/systemctl restart ssh\n"
109+
"Mar 10 08:21:05 example-host pam_unix(sudo:session): session opened for user root by alice(uid=0)\n"
110+
"Mar 10 08:22:10 example-host sudo: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/journalctl -xe\n"
111+
"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");
112+
}
113+
90114
void test_default_thresholds() {
91115
const auto events = build_events();
92116
const loglens::Detector detector;
@@ -149,6 +173,61 @@ void test_failed_publickey_contributes_to_bruteforce_by_default() {
149173
expect(brute_force->event_count == 5, "expected publickey evidence to raise brute force count to five");
150174
}
151175

176+
void test_sudo_signals_include_command_and_session_opened() {
177+
const auto events = build_sudo_signal_candidate_events();
178+
const auto signals = loglens::build_auth_signals(events, loglens::DetectorConfig{}.auth_signal_mappings);
179+
180+
expect(signals.size() == 2, "expected sudo command and supported sudo session-opened signals only");
181+
expect(count_signals(signals, loglens::AuthSignalKind::SudoCommand) == 1,
182+
"expected one sudo command signal");
183+
expect(count_signals(signals, loglens::AuthSignalKind::SudoSessionOpened) == 1,
184+
"expected one sudo session-opened signal");
185+
186+
const auto* sudo_command = find_signal(signals, loglens::AuthSignalKind::SudoCommand);
187+
expect(sudo_command != nullptr, "expected sudo command signal");
188+
expect(sudo_command->counts_as_sudo_burst_evidence,
189+
"expected sudo command signal to count toward sudo burst evidence");
190+
expect(!sudo_command->counts_as_attempt_evidence, "did not expect sudo command to count as auth attempt evidence");
191+
expect(!sudo_command->counts_as_terminal_auth_failure,
192+
"did not expect sudo command to count as terminal auth failure");
193+
194+
const auto* sudo_session = find_signal(signals, loglens::AuthSignalKind::SudoSessionOpened);
195+
expect(sudo_session != nullptr, "expected sudo session-opened signal");
196+
expect(!sudo_session->counts_as_sudo_burst_evidence,
197+
"expected sudo session-opened signal to stay out of sudo burst counting by default");
198+
expect(!sudo_session->counts_as_attempt_evidence,
199+
"did not expect sudo session-opened to count as auth attempt evidence");
200+
expect(!sudo_session->counts_as_terminal_auth_failure,
201+
"did not expect sudo session-opened to count as terminal auth failure");
202+
}
203+
204+
void test_sudo_burst_behavior_is_preserved_with_signal_layer() {
205+
const auto events = build_sudo_burst_preservation_events();
206+
const loglens::Detector detector;
207+
const auto findings = detector.analyze(events);
208+
209+
const auto* sudo = find_finding(findings, loglens::FindingType::SudoBurst, "alice");
210+
expect(sudo != nullptr, "expected sudo burst finding");
211+
expect(sudo->event_count == 3,
212+
"expected sudo burst count to remain based on command events rather than session-opened lines");
213+
}
214+
215+
void test_unsupported_pam_session_close_remains_telemetry_only() {
216+
const loglens::AuthLogParser parser(make_syslog_config());
217+
std::istringstream input(
218+
"Mar 10 09:06:10 example-host pam_unix(sudo:session): session closed for user alice\n");
219+
220+
const auto result = parser.parse_stream(input);
221+
expect(result.events.empty(), "expected unsupported session-close line to stay out of parsed events");
222+
expect(result.warnings.size() == 1, "expected unsupported session-close line to produce one warning");
223+
expect(result.quality.top_unknown_patterns.size() == 1, "expected one unknown pattern bucket");
224+
expect(result.quality.top_unknown_patterns.front().pattern == "pam_unix_other",
225+
"expected unsupported session-close line to remain in pam_unix_other telemetry");
226+
227+
const auto signals = loglens::build_auth_signals(result.events, loglens::DetectorConfig{}.auth_signal_mappings);
228+
expect(signals.empty(), "expected unsupported session-close line to stay out of the signal layer");
229+
}
230+
152231
void test_pam_auth_failure_does_not_trigger_bruteforce_by_default() {
153232
const auto events = build_pam_bruteforce_candidate_events();
154233
const loglens::Detector detector;
@@ -264,6 +343,9 @@ int main() {
264343
test_custom_thresholds();
265344
test_auth_signal_defaults();
266345
test_failed_publickey_contributes_to_bruteforce_by_default();
346+
test_sudo_signals_include_command_and_session_opened();
347+
test_sudo_burst_behavior_is_preserved_with_signal_layer();
348+
test_unsupported_pam_session_close_remains_telemetry_only();
267349
test_pam_auth_failure_does_not_trigger_bruteforce_by_default();
268350
test_equivalent_attack_scenario_yields_same_finding_count_across_modes();
269351
test_load_valid_config();

0 commit comments

Comments
 (0)