Skip to content

Commit 192a06a

Browse files
committed
feat(report): add finding explainability fields
1 parent 664d6aa commit 192a06a

14 files changed

Lines changed: 242 additions & 16 deletions

File tree

docs/report-artifacts.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ The JSON report keeps parser observability visible next to findings:
3737
- `findings`
3838
- `warnings`
3939

40-
Finding objects contain `rule`, `subject_kind`, `subject`, `event_count`, `window_start`, `window_end`, `usernames`, and `summary`.
40+
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`.
41+
42+
`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.
4143

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

docs/rule-catalog.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ Metadata equivalent:
2626
- Default values below match the built-in detector configuration.
2727
- The checked-in `assets/sample_config.json` is a tested default-equivalent fixture.
2828

29+
## Finding Explainability Fields
30+
31+
JSON findings include both the finding conclusion and the rule context used to reach it:
32+
33+
- `rule_id`: stable rule identifier
34+
- `grouping_key`: the normalized field used to group evidence
35+
- `threshold`: configured threshold for the rule
36+
- `observed_count`: observed value compared against the threshold
37+
- `window_start` and `window_end`: selected evidence window
38+
- `evidence_event_ids`: deterministic local event IDs in the selected window, formatted as `line:<number>`
39+
40+
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.
41+
2942
## Brute Force
3043

3144
### Rule name

src/detector.cpp

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ std::vector<const AuthSignal*> sort_signals_by_time(const std::vector<const Auth
1919
return sorted;
2020
}
2121

22+
std::vector<std::string> evidence_event_ids_for_window(const std::vector<const AuthSignal*>& ordered,
23+
std::size_t start,
24+
std::size_t end) {
25+
std::vector<std::string> event_ids;
26+
event_ids.reserve(end - start + 1);
27+
28+
for (std::size_t index = start; index <= end; ++index) {
29+
if (!ordered[index]->event_id.empty()) {
30+
event_ids.push_back(ordered[index]->event_id);
31+
} else {
32+
event_ids.push_back("line:" + std::to_string(ordered[index]->line_number));
33+
}
34+
}
35+
36+
return event_ids;
37+
}
38+
2239
SignalGroup group_terminal_auth_failures_by_ip(const std::vector<AuthSignal>& signals) {
2340
SignalGroup grouped;
2441
for (const auto& signal : signals) {
@@ -54,34 +71,49 @@ SignalGroup group_sudo_burst_evidence_by_user(const std::vector<AuthSignal>& sig
5471

5572
Finding make_brute_force_finding(const std::string& ip,
5673
std::size_t count,
74+
std::size_t threshold,
5775
std::chrono::sys_seconds first_seen,
5876
std::chrono::sys_seconds last_seen,
59-
std::chrono::minutes window) {
77+
std::chrono::minutes window,
78+
std::vector<std::string> evidence_event_ids) {
6079
Finding finding;
6180
finding.type = FindingType::BruteForce;
81+
finding.rule_id = to_string(finding.type);
6282
finding.subject_kind = "source_ip";
6383
finding.subject = ip;
84+
finding.grouping_key = "source_ip";
85+
finding.threshold = threshold;
86+
finding.observed_count = count;
6487
finding.event_count = count;
6588
finding.first_seen = first_seen;
6689
finding.last_seen = last_seen;
90+
finding.evidence_event_ids = std::move(evidence_event_ids);
6791
finding.summary = std::to_string(count) + " failed SSH attempts from " + ip
6892
+ " within " + std::to_string(window.count()) + " minutes.";
6993
return finding;
7094
}
7195

7296
Finding make_multi_user_finding(const std::string& ip,
7397
std::size_t count,
98+
std::size_t threshold,
99+
std::size_t distinct_username_count,
74100
std::chrono::sys_seconds first_seen,
75101
std::chrono::sys_seconds last_seen,
76102
std::vector<std::string> usernames,
77-
std::chrono::minutes window) {
103+
std::chrono::minutes window,
104+
std::vector<std::string> evidence_event_ids) {
78105
Finding finding;
79106
finding.type = FindingType::MultiUserProbing;
107+
finding.rule_id = to_string(finding.type);
80108
finding.subject_kind = "source_ip";
81109
finding.subject = ip;
110+
finding.grouping_key = "source_ip";
111+
finding.threshold = threshold;
112+
finding.observed_count = distinct_username_count;
82113
finding.event_count = count;
83114
finding.first_seen = first_seen;
84115
finding.last_seen = last_seen;
116+
finding.evidence_event_ids = std::move(evidence_event_ids);
85117
finding.usernames = std::move(usernames);
86118
finding.summary = ip + " targeted " + std::to_string(finding.usernames.size())
87119
+ " usernames within " + std::to_string(window.count()) + " minutes.";
@@ -90,16 +122,23 @@ Finding make_multi_user_finding(const std::string& ip,
90122

91123
Finding make_sudo_finding(const std::string& user,
92124
std::size_t count,
125+
std::size_t threshold,
93126
std::chrono::sys_seconds first_seen,
94127
std::chrono::sys_seconds last_seen,
95-
std::chrono::minutes window) {
128+
std::chrono::minutes window,
129+
std::vector<std::string> evidence_event_ids) {
96130
Finding finding;
97131
finding.type = FindingType::SudoBurst;
132+
finding.rule_id = to_string(finding.type);
98133
finding.subject_kind = "username";
99134
finding.subject = user;
135+
finding.grouping_key = "username";
136+
finding.threshold = threshold;
137+
finding.observed_count = count;
100138
finding.event_count = count;
101139
finding.first_seen = first_seen;
102140
finding.last_seen = last_seen;
141+
finding.evidence_event_ids = std::move(evidence_event_ids);
103142
finding.summary = user + " ran " + std::to_string(count)
104143
+ " sudo commands within " + std::to_string(window.count()) + " minutes.";
105144
return finding;
@@ -134,9 +173,11 @@ std::vector<Finding> detect_brute_force(const std::vector<AuthSignal>& signals,
134173
findings.push_back(make_brute_force_finding(
135174
ip,
136175
best_count,
176+
config.brute_force.threshold,
137177
ordered[best_start]->timestamp,
138178
ordered[best_end]->timestamp,
139-
config.brute_force.window));
179+
config.brute_force.window,
180+
evidence_event_ids_for_window(ordered, best_start, best_end)));
140181
}
141182
}
142183

@@ -198,10 +239,13 @@ std::vector<Finding> detect_multi_user(const std::vector<AuthSignal>& signals, c
198239
findings.push_back(make_multi_user_finding(
199240
ip,
200241
best_count,
242+
config.multi_user_probing.threshold,
243+
best_distinct,
201244
ordered[best_start]->timestamp,
202245
ordered[best_end]->timestamp,
203246
best_usernames,
204-
config.multi_user_probing.window));
247+
config.multi_user_probing.window,
248+
evidence_event_ids_for_window(ordered, best_start, best_end)));
205249
}
206250
}
207251

@@ -237,9 +281,11 @@ std::vector<Finding> detect_sudo_burst(const std::vector<AuthSignal>& signals, c
237281
findings.push_back(make_sudo_finding(
238282
username,
239283
best_count,
284+
config.sudo_burst.threshold,
240285
ordered[best_start]->timestamp,
241286
ordered[best_end]->timestamp,
242-
config.sudo_burst.window));
287+
config.sudo_burst.window,
288+
evidence_event_ids_for_window(ordered, best_start, best_end)));
243289
}
244290
}
245291

src/detector.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,16 @@ struct DetectorConfig {
2929

3030
struct Finding {
3131
FindingType type = FindingType::BruteForce;
32+
std::string rule_id;
3233
std::string subject_kind;
3334
std::string subject;
35+
std::string grouping_key;
36+
std::size_t threshold = 0;
37+
std::size_t observed_count = 0;
3438
std::size_t event_count = 0;
3539
std::chrono::sys_seconds first_seen{};
3640
std::chrono::sys_seconds last_seen{};
41+
std::vector<std::string> evidence_event_ids;
3742
std::vector<std::string> usernames;
3843
std::string summary;
3944
};

src/report.cpp

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,38 @@ std::string usernames_csv_field(const Finding& finding) {
291291
return usernames.str();
292292
}
293293

294+
std::string finding_rule_id(const Finding& finding) {
295+
if (!finding.rule_id.empty()) {
296+
return finding.rule_id;
297+
}
298+
return to_string(finding.type);
299+
}
300+
301+
std::string finding_grouping_key(const Finding& finding) {
302+
if (!finding.grouping_key.empty()) {
303+
return finding.grouping_key;
304+
}
305+
return finding.subject_kind;
306+
}
307+
308+
std::size_t finding_observed_count(const Finding& finding) {
309+
if (finding.observed_count != 0) {
310+
return finding.observed_count;
311+
}
312+
return finding.event_count;
313+
}
314+
315+
void write_json_string_array(std::ostream& output, const std::vector<std::string>& values) {
316+
output << '[';
317+
for (std::size_t index = 0; index < values.size(); ++index) {
318+
output << '"' << escape_json(values[index]) << '"';
319+
if (index + 1 != values.size()) {
320+
output << ", ";
321+
}
322+
}
323+
output << ']';
324+
}
325+
294326
std::string format_parse_success_rate(double rate) {
295327
std::ostringstream output;
296328
output << std::fixed << std::setprecision(4) << rate;
@@ -651,20 +683,22 @@ std::string render_json_report(const ReportData& data) {
651683
for (std::size_t index = 0; index < findings.size(); ++index) {
652684
const auto& finding = findings[index];
653685
output << " {\n";
686+
output << " \"rule_id\": \"" << escape_json(finding_rule_id(finding)) << "\",\n";
654687
output << " \"rule\": \"" << to_string(finding.type) << "\",\n";
655688
output << " \"subject_kind\": \"" << escape_json(finding.subject_kind) << "\",\n";
656689
output << " \"subject\": \"" << escape_json(finding.subject) << "\",\n";
690+
output << " \"grouping_key\": \"" << escape_json(finding_grouping_key(finding)) << "\",\n";
691+
output << " \"threshold\": " << finding.threshold << ",\n";
692+
output << " \"observed_count\": " << finding_observed_count(finding) << ",\n";
657693
output << " \"event_count\": " << finding.event_count << ",\n";
658694
output << " \"window_start\": \"" << format_timestamp(finding.first_seen) << "\",\n";
659695
output << " \"window_end\": \"" << format_timestamp(finding.last_seen) << "\",\n";
660-
output << " \"usernames\": [";
661-
for (std::size_t name_index = 0; name_index < finding.usernames.size(); ++name_index) {
662-
output << '"' << escape_json(finding.usernames[name_index]) << '"';
663-
if (name_index + 1 != finding.usernames.size()) {
664-
output << ", ";
665-
}
666-
}
667-
output << "],\n";
696+
output << " \"evidence_event_ids\": ";
697+
write_json_string_array(output, finding.evidence_event_ids);
698+
output << ",\n";
699+
output << " \"usernames\": ";
700+
write_json_string_array(output, finding.usernames);
701+
output << ",\n";
668702
output << " \"summary\": \"" << escape_json(finding.summary) << "\"\n";
669703
output << " }";
670704
output << (index + 1 == findings.size() ? "\n" : ",\n");

src/signal.cpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
namespace loglens {
66
namespace {
77

8+
std::string event_id_for(const Event& event) {
9+
return "line:" + std::to_string(event.line_number);
10+
}
11+
812
struct SignalMapping {
913
AuthSignalKind signal_kind = AuthSignalKind::Unknown;
1014
bool counts_as_attempt_evidence = false;
@@ -97,7 +101,8 @@ std::vector<AuthSignal> build_auth_signals(const std::vector<Event>& events, con
97101
mapping->counts_as_attempt_evidence,
98102
mapping->counts_as_terminal_auth_failure,
99103
mapping->counts_as_sudo_burst_evidence,
100-
event.line_number});
104+
event.line_number,
105+
event_id_for(event)});
101106
}
102107

103108
return signals;

src/signal.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ struct AuthSignal {
4343
bool counts_as_terminal_auth_failure = false;
4444
bool counts_as_sudo_burst_evidence = false;
4545
std::size_t line_number = 0;
46+
std::string event_id;
4647
};
4748

4849
std::vector<AuthSignal> build_auth_signals(const std::vector<Event>& events, const AuthSignalConfig& config);

tests/fixtures/report_contracts/journalctl_short_full/report.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,32 +29,47 @@
2929
],
3030
"findings": [
3131
{
32+
"rule_id": "brute_force",
3233
"rule": "brute_force",
3334
"subject_kind": "source_ip",
3435
"subject": "203.0.113.10",
36+
"grouping_key": "source_ip",
37+
"threshold": 5,
38+
"observed_count": 5,
3539
"event_count": 5,
3640
"window_start": "2026-03-10 08:11:22",
3741
"window_end": "2026-03-10 08:18:05",
42+
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
3843
"usernames": [],
3944
"summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes."
4045
},
4146
{
47+
"rule_id": "multi_user_probing",
4248
"rule": "multi_user_probing",
4349
"subject_kind": "source_ip",
4450
"subject": "203.0.113.10",
51+
"grouping_key": "source_ip",
52+
"threshold": 3,
53+
"observed_count": 5,
4554
"event_count": 5,
4655
"window_start": "2026-03-10 08:11:22",
4756
"window_end": "2026-03-10 08:18:05",
57+
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
4858
"usernames": ["admin", "deploy", "guest", "root", "test"],
4959
"summary": "203.0.113.10 targeted 5 usernames within 15 minutes."
5060
},
5161
{
62+
"rule_id": "sudo_burst",
5263
"rule": "sudo_burst",
5364
"subject_kind": "username",
5465
"subject": "alice",
66+
"grouping_key": "username",
67+
"threshold": 3,
68+
"observed_count": 3,
5569
"event_count": 3,
5670
"window_start": "2026-03-10 08:21:00",
5771
"window_end": "2026-03-10 08:24:15",
72+
"evidence_event_ids": ["line:7", "line:8", "line:9"],
5873
"usernames": [],
5974
"summary": "alice ran 3 sudo commands within 5 minutes."
6075
}

tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,32 +56,47 @@
5656
],
5757
"findings": [
5858
{
59+
"rule_id": "brute_force",
5960
"rule": "brute_force",
6061
"subject_kind": "source_ip",
6162
"subject": "203.0.113.10",
63+
"grouping_key": "source_ip",
64+
"threshold": 5,
65+
"observed_count": 5,
6266
"event_count": 5,
6367
"window_start": "2026-03-11 09:00:00",
6468
"window_end": "2026-03-11 09:04:05",
69+
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
6570
"usernames": [],
6671
"summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes."
6772
},
6873
{
74+
"rule_id": "multi_user_probing",
6975
"rule": "multi_user_probing",
7076
"subject_kind": "source_ip",
7177
"subject": "203.0.113.10",
78+
"grouping_key": "source_ip",
79+
"threshold": 3,
80+
"observed_count": 5,
7281
"event_count": 5,
7382
"window_start": "2026-03-11 09:00:00",
7483
"window_end": "2026-03-11 09:04:05",
84+
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
7585
"usernames": ["admin", "deploy", "guest", "root", "test"],
7686
"summary": "203.0.113.10 targeted 5 usernames within 15 minutes."
7787
},
7888
{
89+
"rule_id": "sudo_burst",
7990
"rule": "sudo_burst",
8091
"subject_kind": "username",
8192
"subject": "alice",
93+
"grouping_key": "username",
94+
"threshold": 3,
95+
"observed_count": 3,
8296
"event_count": 3,
8397
"window_start": "2026-03-11 09:11:00",
8498
"window_end": "2026-03-11 09:14:15",
99+
"evidence_event_ids": ["line:9", "line:10", "line:13"],
85100
"usernames": [],
86101
"summary": "alice ran 3 sudo commands within 5 minutes."
87102
}

0 commit comments

Comments
 (0)