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
4 changes: 3 additions & 1 deletion docs/report-artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ The JSON report keeps parser observability visible next to findings:
- `findings`
- `warnings`

Finding objects contain `rule`, `subject_kind`, `subject`, `event_count`, `window_start`, `window_end`, `usernames`, and `summary`.
Finding objects contain `rule_id`, `rule`, `subject_kind`, `subject`, `grouping_key`, `threshold`, `observed_count`, `event_count`, `window_start`, `window_end`, `evidence_event_ids`, `usernames`, and `summary`.

`evidence_event_ids` are deterministic local event identifiers derived from the source line number, formatted as `line:<number>`. They let reviewers trace a finding back to the normalized input events that satisfied the rule window without implying global event identity.

Warning objects contain the original `line_number` and the parser `reason`.

Expand Down
13 changes: 13 additions & 0 deletions docs/rule-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ Metadata equivalent:
- Default values below match the built-in detector configuration.
- The checked-in `assets/sample_config.json` is a tested default-equivalent fixture.

## Finding Explainability Fields

JSON findings include both the finding conclusion and the rule context used to reach it:

- `rule_id`: stable rule identifier
- `grouping_key`: the normalized field used to group evidence
- `threshold`: configured threshold for the rule
- `observed_count`: observed value compared against the threshold
- `window_start` and `window_end`: selected evidence window
- `evidence_event_ids`: deterministic local event IDs in the selected window, formatted as `line:<number>`

For `multi_user_probing`, `observed_count` is the distinct username count, while `event_count` remains the number of attempt-evidence events in the selected window.

## Brute Force

### Rule name
Expand Down
58 changes: 52 additions & 6 deletions src/detector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ std::vector<const AuthSignal*> sort_signals_by_time(const std::vector<const Auth
return sorted;
}

std::vector<std::string> evidence_event_ids_for_window(const std::vector<const AuthSignal*>& ordered,
std::size_t start,
std::size_t end) {
std::vector<std::string> event_ids;
event_ids.reserve(end - start + 1);

for (std::size_t index = start; index <= end; ++index) {
if (!ordered[index]->event_id.empty()) {
event_ids.push_back(ordered[index]->event_id);
} else {
event_ids.push_back("line:" + std::to_string(ordered[index]->line_number));
}
}

return event_ids;
}

SignalGroup group_terminal_auth_failures_by_ip(const std::vector<AuthSignal>& signals) {
SignalGroup grouped;
for (const auto& signal : signals) {
Expand Down Expand Up @@ -54,34 +71,49 @@ SignalGroup group_sudo_burst_evidence_by_user(const std::vector<AuthSignal>& sig

Finding make_brute_force_finding(const std::string& ip,
std::size_t count,
std::size_t threshold,
std::chrono::sys_seconds first_seen,
std::chrono::sys_seconds last_seen,
std::chrono::minutes window) {
std::chrono::minutes window,
std::vector<std::string> evidence_event_ids) {
Finding finding;
finding.type = FindingType::BruteForce;
finding.rule_id = to_string(finding.type);
finding.subject_kind = "source_ip";
finding.subject = ip;
finding.grouping_key = "source_ip";
finding.threshold = threshold;
finding.observed_count = count;
finding.event_count = count;
finding.first_seen = first_seen;
finding.last_seen = last_seen;
finding.evidence_event_ids = std::move(evidence_event_ids);
finding.summary = std::to_string(count) + " failed SSH attempts from " + ip
+ " within " + std::to_string(window.count()) + " minutes.";
return finding;
}

Finding make_multi_user_finding(const std::string& ip,
std::size_t count,
std::size_t threshold,
std::size_t distinct_username_count,
std::chrono::sys_seconds first_seen,
std::chrono::sys_seconds last_seen,
std::vector<std::string> usernames,
std::chrono::minutes window) {
std::chrono::minutes window,
std::vector<std::string> evidence_event_ids) {
Finding finding;
finding.type = FindingType::MultiUserProbing;
finding.rule_id = to_string(finding.type);
finding.subject_kind = "source_ip";
finding.subject = ip;
finding.grouping_key = "source_ip";
finding.threshold = threshold;
finding.observed_count = distinct_username_count;
finding.event_count = count;
finding.first_seen = first_seen;
finding.last_seen = last_seen;
finding.evidence_event_ids = std::move(evidence_event_ids);
finding.usernames = std::move(usernames);
finding.summary = ip + " targeted " + std::to_string(finding.usernames.size())
+ " usernames within " + std::to_string(window.count()) + " minutes.";
Expand All @@ -90,16 +122,23 @@ Finding make_multi_user_finding(const std::string& ip,

Finding make_sudo_finding(const std::string& user,
std::size_t count,
std::size_t threshold,
std::chrono::sys_seconds first_seen,
std::chrono::sys_seconds last_seen,
std::chrono::minutes window) {
std::chrono::minutes window,
std::vector<std::string> evidence_event_ids) {
Finding finding;
finding.type = FindingType::SudoBurst;
finding.rule_id = to_string(finding.type);
finding.subject_kind = "username";
finding.subject = user;
finding.grouping_key = "username";
finding.threshold = threshold;
finding.observed_count = count;
finding.event_count = count;
finding.first_seen = first_seen;
finding.last_seen = last_seen;
finding.evidence_event_ids = std::move(evidence_event_ids);
finding.summary = user + " ran " + std::to_string(count)
+ " sudo commands within " + std::to_string(window.count()) + " minutes.";
return finding;
Expand Down Expand Up @@ -134,9 +173,11 @@ std::vector<Finding> detect_brute_force(const std::vector<AuthSignal>& signals,
findings.push_back(make_brute_force_finding(
ip,
best_count,
config.brute_force.threshold,
ordered[best_start]->timestamp,
ordered[best_end]->timestamp,
config.brute_force.window));
config.brute_force.window,
evidence_event_ids_for_window(ordered, best_start, best_end)));
}
}

Expand Down Expand Up @@ -198,10 +239,13 @@ std::vector<Finding> detect_multi_user(const std::vector<AuthSignal>& signals, c
findings.push_back(make_multi_user_finding(
ip,
best_count,
config.multi_user_probing.threshold,
best_distinct,
ordered[best_start]->timestamp,
ordered[best_end]->timestamp,
best_usernames,
config.multi_user_probing.window));
config.multi_user_probing.window,
evidence_event_ids_for_window(ordered, best_start, best_end)));
}
}

Expand Down Expand Up @@ -237,9 +281,11 @@ std::vector<Finding> detect_sudo_burst(const std::vector<AuthSignal>& signals, c
findings.push_back(make_sudo_finding(
username,
best_count,
config.sudo_burst.threshold,
ordered[best_start]->timestamp,
ordered[best_end]->timestamp,
config.sudo_burst.window));
config.sudo_burst.window,
evidence_event_ids_for_window(ordered, best_start, best_end)));
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/detector.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ struct DetectorConfig {

struct Finding {
FindingType type = FindingType::BruteForce;
std::string rule_id;
std::string subject_kind;
std::string subject;
std::string grouping_key;
std::size_t threshold = 0;
std::size_t observed_count = 0;
std::size_t event_count = 0;
std::chrono::sys_seconds first_seen{};
std::chrono::sys_seconds last_seen{};
std::vector<std::string> evidence_event_ids;
std::vector<std::string> usernames;
std::string summary;
};
Expand Down
50 changes: 42 additions & 8 deletions src/report.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,38 @@ std::string usernames_csv_field(const Finding& finding) {
return usernames.str();
}

std::string finding_rule_id(const Finding& finding) {
if (!finding.rule_id.empty()) {
return finding.rule_id;
}
return to_string(finding.type);
}

std::string finding_grouping_key(const Finding& finding) {
if (!finding.grouping_key.empty()) {
return finding.grouping_key;
}
return finding.subject_kind;
}

std::size_t finding_observed_count(const Finding& finding) {
if (finding.observed_count != 0) {
return finding.observed_count;
}
return finding.event_count;
}

void write_json_string_array(std::ostream& output, const std::vector<std::string>& values) {
output << '[';
for (std::size_t index = 0; index < values.size(); ++index) {
output << '"' << escape_json(values[index]) << '"';
if (index + 1 != values.size()) {
output << ", ";
}
}
output << ']';
}

std::string format_parse_success_rate(double rate) {
std::ostringstream output;
output << std::fixed << std::setprecision(4) << rate;
Expand Down Expand Up @@ -651,20 +683,22 @@ std::string render_json_report(const ReportData& data) {
for (std::size_t index = 0; index < findings.size(); ++index) {
const auto& finding = findings[index];
output << " {\n";
output << " \"rule_id\": \"" << escape_json(finding_rule_id(finding)) << "\",\n";
output << " \"rule\": \"" << to_string(finding.type) << "\",\n";
output << " \"subject_kind\": \"" << escape_json(finding.subject_kind) << "\",\n";
output << " \"subject\": \"" << escape_json(finding.subject) << "\",\n";
output << " \"grouping_key\": \"" << escape_json(finding_grouping_key(finding)) << "\",\n";
output << " \"threshold\": " << finding.threshold << ",\n";
output << " \"observed_count\": " << finding_observed_count(finding) << ",\n";
output << " \"event_count\": " << finding.event_count << ",\n";
output << " \"window_start\": \"" << format_timestamp(finding.first_seen) << "\",\n";
output << " \"window_end\": \"" << format_timestamp(finding.last_seen) << "\",\n";
output << " \"usernames\": [";
for (std::size_t name_index = 0; name_index < finding.usernames.size(); ++name_index) {
output << '"' << escape_json(finding.usernames[name_index]) << '"';
if (name_index + 1 != finding.usernames.size()) {
output << ", ";
}
}
output << "],\n";
output << " \"evidence_event_ids\": ";
write_json_string_array(output, finding.evidence_event_ids);
output << ",\n";
output << " \"usernames\": ";
write_json_string_array(output, finding.usernames);
output << ",\n";
output << " \"summary\": \"" << escape_json(finding.summary) << "\"\n";
output << " }";
output << (index + 1 == findings.size() ? "\n" : ",\n");
Expand Down
7 changes: 6 additions & 1 deletion src/signal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
namespace loglens {
namespace {

std::string event_id_for(const Event& event) {
return "line:" + std::to_string(event.line_number);
}

struct SignalMapping {
AuthSignalKind signal_kind = AuthSignalKind::Unknown;
bool counts_as_attempt_evidence = false;
Expand Down Expand Up @@ -97,7 +101,8 @@ std::vector<AuthSignal> build_auth_signals(const std::vector<Event>& events, con
mapping->counts_as_attempt_evidence,
mapping->counts_as_terminal_auth_failure,
mapping->counts_as_sudo_burst_evidence,
event.line_number});
event.line_number,
event_id_for(event)});
}

return signals;
Expand Down
1 change: 1 addition & 0 deletions src/signal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ struct AuthSignal {
bool counts_as_terminal_auth_failure = false;
bool counts_as_sudo_burst_evidence = false;
std::size_t line_number = 0;
std::string event_id;
};

std::vector<AuthSignal> build_auth_signals(const std::vector<Event>& events, const AuthSignalConfig& config);
Expand Down
15 changes: 15 additions & 0 deletions tests/fixtures/report_contracts/journalctl_short_full/report.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,32 +29,47 @@
],
"findings": [
{
"rule_id": "brute_force",
"rule": "brute_force",
"subject_kind": "source_ip",
"subject": "203.0.113.10",
"grouping_key": "source_ip",
"threshold": 5,
"observed_count": 5,
"event_count": 5,
"window_start": "2026-03-10 08:11:22",
"window_end": "2026-03-10 08:18:05",
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
"usernames": [],
"summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes."
},
{
"rule_id": "multi_user_probing",
"rule": "multi_user_probing",
"subject_kind": "source_ip",
"subject": "203.0.113.10",
"grouping_key": "source_ip",
"threshold": 3,
"observed_count": 5,
"event_count": 5,
"window_start": "2026-03-10 08:11:22",
"window_end": "2026-03-10 08:18:05",
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
"usernames": ["admin", "deploy", "guest", "root", "test"],
"summary": "203.0.113.10 targeted 5 usernames within 15 minutes."
},
{
"rule_id": "sudo_burst",
"rule": "sudo_burst",
"subject_kind": "username",
"subject": "alice",
"grouping_key": "username",
"threshold": 3,
"observed_count": 3,
"event_count": 3,
"window_start": "2026-03-10 08:21:00",
"window_end": "2026-03-10 08:24:15",
"evidence_event_ids": ["line:7", "line:8", "line:9"],
"usernames": [],
"summary": "alice ran 3 sudo commands within 5 minutes."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,32 +56,47 @@
],
"findings": [
{
"rule_id": "brute_force",
"rule": "brute_force",
"subject_kind": "source_ip",
"subject": "203.0.113.10",
"grouping_key": "source_ip",
"threshold": 5,
"observed_count": 5,
"event_count": 5,
"window_start": "2026-03-11 09:00:00",
"window_end": "2026-03-11 09:04:05",
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
"usernames": [],
"summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes."
},
{
"rule_id": "multi_user_probing",
"rule": "multi_user_probing",
"subject_kind": "source_ip",
"subject": "203.0.113.10",
"grouping_key": "source_ip",
"threshold": 3,
"observed_count": 5,
"event_count": 5,
"window_start": "2026-03-11 09:00:00",
"window_end": "2026-03-11 09:04:05",
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
"usernames": ["admin", "deploy", "guest", "root", "test"],
"summary": "203.0.113.10 targeted 5 usernames within 15 minutes."
},
{
"rule_id": "sudo_burst",
"rule": "sudo_burst",
"subject_kind": "username",
"subject": "alice",
"grouping_key": "username",
"threshold": 3,
"observed_count": 3,
"event_count": 3,
"window_start": "2026-03-11 09:11:00",
"window_end": "2026-03-11 09:14:15",
"evidence_event_ids": ["line:9", "line:10", "line:13"],
"usernames": [],
"summary": "alice ran 3 sudo commands within 5 minutes."
}
Expand Down
Loading
Loading