Skip to content

Commit cd9d052

Browse files
committed
Merge origin/main into codex/docs/release-facing-docs-v0.2
2 parents 5e43e42 + adb135f commit cd9d052

10 files changed

Lines changed: 320 additions & 109 deletions

AGENTS.md

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,14 @@
11
# AGENTS.md
22

3-
## Project
4-
LogLens is a defensive C++20 CLI for parsing Linux authentication logs and generating structured detection reports.
5-
6-
## Priorities
7-
1. Working MVP first
8-
2. Clean modular C++20
9-
3. Safe public-repo content
10-
4. Reproducible build and tests
11-
5. Clear README and docs
12-
13-
## Constraints
14-
- Do not add offensive or exploitation functionality
15-
- Do not use real IPs, secrets, usernames, or private infrastructure identifiers
16-
- Prefer standard library over third-party dependencies
17-
- Keep file structure simple
18-
- Avoid unnecessary templates or meta-programming
19-
- Avoid heavy regex-only designs if a clearer parser is possible
20-
- Keep detection rules centralized and configurable
21-
22-
## Code style
23-
- C++20
24-
- Readable names
25-
- Small functions
26-
- Comments only where they add real value
27-
- Fail gracefully on malformed log lines
28-
29-
## Repository rules
30-
- Always update README when adding user-visible features
31-
- Add or update tests for parser and detector changes
32-
- Preserve public-safe placeholders like 203.0.113.x and example-host
33-
- Do not introduce large unrelated refactors
34-
35-
## Task behavior
36-
When given a task:
37-
1. inspect repository state
38-
2. explain plan briefly
39-
3. implement in small steps
40-
4. run build/tests if available
41-
5. summarize created/modified files and remaining issues
3+
## LogLens Repo Rules
4+
5+
- Keep the repository defensive and public-safe. Do not add offensive, exploitation, persistence, or live attack functionality.
6+
- Use only safe placeholders such as `203.0.113.x` and `example-host`. Never add real IPs, usernames, secrets, or private identifiers.
7+
- Prefer standard C++20 and the standard library. Keep code modular, readable, and easy to extend.
8+
- Keep detection rules centralized and configurable. Avoid large unrelated refactors.
9+
- Fail gracefully on malformed log lines.
10+
- Update README or docs for user-visible changes.
11+
- Tests are required for code changes. Add or update parser/detector tests and run available build/tests when possible:
12+
`cmake -S . -B build`
13+
`cmake --build build`
14+
`ctest --test-dir build --output-on-failure`

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 stacknil
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
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

docs/release-v0.1.0.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
# LogLens v0.1.0
2-
3-
LogLens v0.1.0 is the first public MVP release of the repository.
4-
5-
## Highlights
6-
7-
- Parses Linux authentication logs in both `syslog_legacy` and `journalctl_short_full` modes.
8-
- Normalizes authentication evidence and applies configurable detections for SSH brute force, multi-user probing, and sudo burst activity.
9-
- Reports parser coverage telemetry so unsupported lines are visible instead of silently ignored.
10-
- Ships with deterministic Markdown and JSON reports, unit tests, CI, CodeQL, and baseline repository hardening.
11-
12-
## Notes
13-
14-
- This release is intentionally narrow in scope and focused on a clean, public-safe baseline.
15-
- Parser coverage is limited to a small set of common `sshd`, `sudo`, and `pam_unix` patterns.
16-
- Repository protections are designed for PR-based development with CI and CodeQL gating merges into `main`.
1+
# LogLens v0.1.0
2+
3+
LogLens v0.1.0 is the first public MVP release of the repository.
4+
5+
## Highlights
6+
7+
- Parses Linux authentication logs in both `syslog_legacy` and `journalctl_short_full` modes.
8+
- Normalizes authentication evidence and applies configurable detections for SSH brute force, multi-user probing, and sudo burst activity.
9+
- Reports parser coverage telemetry so unsupported lines are visible instead of silently ignored.
10+
- Ships with deterministic Markdown and JSON reports, unit tests, CI, CodeQL, and baseline repository hardening.
11+
12+
## Notes
13+
14+
- This release is intentionally narrow in scope and focused on a clean, public-safe baseline.
15+
- Parser coverage is limited to a small set of common `sshd`, `sudo`, and `pam_unix` patterns.
16+
- Repository protections are designed for PR-based development with CI and CodeQL gating merges into `main`.

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

0 commit comments

Comments
 (0)