Skip to content

Commit 2ed67af

Browse files
authored
Merge pull request #72 from stacknil/stacknil/v0.5-finding-verdict-boundary
feat(report): add finding verdict boundary
2 parents 735b77d + 4653915 commit 2ed67af

15 files changed

Lines changed: 87 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable user-visible changes should be recorded here.
88

99
- Added sanitized golden `report.md` / `report.json` regression fixtures to lock report contracts.
1010
- Added `schema` and `schema_version` fields to `report.json` so downstream tooling can identify the report artifact contract.
11+
- Added `verdict_boundary` to JSON findings and advanced the report artifact contract to `loglens.report.v2`.
1112
- Expanded parser coverage for `Accepted publickey` and selected `pam_faillock` / `pam_sss` variants.
1213
- Added compact host-level summaries for multi-host reports.
1314
- Added optional CSV export for findings and warnings when explicitly requested.

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ A compact finding summary is a bounded triage signal, not attribution:
1515
{
1616
"rule_id": "brute_force",
1717
"subject_kind": "source_ip",
18-
"subject": "198.51.100.23",
19-
"window": "10m",
18+
"subject": "203.0.113.10",
19+
"window_start": "2026-03-10 08:11:22",
20+
"window_end": "2026-03-10 08:18:05",
2021
"threshold": 5,
21-
"observed": 8,
22-
"verdict_boundary": "triage_signal_not_attribution"
22+
"observed_count": 5,
23+
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
24+
"verdict_boundary": "triage_signal_not_compromise_or_attribution"
2325
}
2426
```
2527

docs/quality-gates.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The main review principle is:
1515
| Parser coverage is visible | [`parser-coverage-notes.md`](./parser-coverage-notes.md), [`tests/fixtures/parser_matrix/noisy_auth_expected.json`](../tests/fixtures/parser_matrix/noisy_auth_expected.json) | `test_parser` compares noisy-auth coverage output to the checked-in expected summary | Reviewer can see parsed lines, skipped blanks, warnings, failure categories, and unknown-pattern buckets |
1616
| Unsupported evidence does not silently become detector evidence | [`parser-contract.md`](./parser-contract.md), [`rule-catalog.md`](./rule-catalog.md), [`case-study-linux-auth-bruteforce.md`](./case-study-linux-auth-bruteforce.md) | `test_parser` covers unknown-pattern warnings; `test_detector` covers signal-boundary behavior | Reviewer can explain why unsupported lines remain warnings instead of findings |
1717
| Report artifacts are deterministic | [`report-artifacts.md`](./report-artifacts.md), report-contract fixtures under [`tests/fixtures/report_contracts`](../tests/fixtures/report_contracts) | `test_report_contracts` compares generated `report.md`, `report.json`, `findings.csv`, and `warnings.csv` against golden fixtures | Reviewer can regenerate reports and see schema or text changes as explicit snapshot diffs |
18-
| Findings are explainable | [`rule-catalog.md`](./rule-catalog.md), [`report-artifacts.md`](./report-artifacts.md) | `test_report` checks JSON finding fields; report-contract fixtures lock `rule_id`, `window_start`, `window_end`, `threshold`, `observed_count`, `grouping_key`, and `evidence_event_ids` | Reviewer can trace a finding from rule context back to source line IDs |
18+
| Findings are explainable | [`rule-catalog.md`](./rule-catalog.md), [`report-artifacts.md`](./report-artifacts.md) | `test_report` checks JSON finding fields; report-contract fixtures lock `rule_id`, `window_start`, `window_end`, `threshold`, `observed_count`, `grouping_key`, `evidence_event_ids`, and `verdict_boundary` | Reviewer can trace a finding from rule context back to source line IDs and see the non-verdict boundary |
1919
| False-positive boundaries are visible | [`rule-catalog.md`](./rule-catalog.md), [`case-study-linux-auth-bruteforce.md`](./case-study-linux-auth-bruteforce.md) | Documentation review gate; detector tests ensure unsupported evidence does not inflate counts | Reviewer can state NAT, internal scanner, lab replay, shared bastion, scheduled admin task, and malformed replay boundaries |
2020
| Parser failure taxonomy is exposed | [`parser-contract.md`](./parser-contract.md), [`parser-conformance-matrix.md`](./parser-conformance-matrix.md), [`report-artifacts.md`](./report-artifacts.md) | `test_parser`, `test_report`, `test_cli`, and `test_report_contracts` cover `failure_categories` and warning `category` output | Reviewer can distinguish timestamp, program, known-program unknown-message, malformed-source-IP, and unsupported-PAM failures |
2121
| Local scale expectations are reproducible | [`performance-envelope.md`](./performance-envelope.md), [`scripts/benchmark-performance-envelope.ps1`](../scripts/benchmark-performance-envelope.ps1) | `pwsh -File scripts/benchmark-performance-envelope.ps1` regenerates sanitized benchmark inputs and local summary artifacts | Reviewer can reproduce the 1k/10k/100k-line envelope and understand its caveats |

docs/report-artifacts.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,24 @@ The JSON report keeps parser observability visible next to findings:
4040
- `findings`
4141
- `warnings`
4242

43-
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`.
43+
Finding objects contain `rule_id`, `rule`, `subject_kind`, `subject`, `grouping_key`, `threshold`, `observed_count`, `event_count`, `window_start`, `window_end`, `evidence_event_ids`, `verdict_boundary`, `usernames`, and `summary`.
4444

4545
`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.
4646

47+
`verdict_boundary` is a stable token that states what the finding must not be
48+
read as. It keeps machine-readable findings aligned with LogLens's triage
49+
scope:
50+
51+
- `triage_signal_not_compromise_or_attribution`
52+
- `triage_signal_not_intent_or_attribution`
53+
- `triage_signal_not_maliciousness_or_authorization`
54+
4755
Warning objects contain the original `line_number`, parser `category`, and parser `reason`.
4856

4957
`schema` and `schema_version` identify the report artifact contract, not the
5058
application release. They are intended for downstream tooling that needs a
5159
stable way to reject incompatible report shapes. The current JSON contract is
52-
`loglens.report.v1` with `schema_version` set to `1`.
60+
`loglens.report.v2` with `schema_version` set to `2`.
5361

5462
Parser failure categories are stable reviewer-facing buckets for unsupported
5563
lines: `unknown_timestamp`, `unknown_program`,

docs/rule-catalog.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,18 @@ JSON findings include both the finding conclusion and the rule context used to r
3636
- `observed_count`: observed value compared against the threshold
3737
- `window_start` and `window_end`: selected evidence window
3838
- `evidence_event_ids`: deterministic local event IDs in the selected window, formatted as `line:<number>`
39+
- `verdict_boundary`: stable token that names the non-verdict boundary for the finding
3940

4041
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.
4142

43+
Current `verdict_boundary` values are:
44+
45+
| Rule | `verdict_boundary` |
46+
| --- | --- |
47+
| `brute_force` | `triage_signal_not_compromise_or_attribution` |
48+
| `multi_user_probing` | `triage_signal_not_intent_or_attribution` |
49+
| `sudo_burst` | `triage_signal_not_maliciousness_or_authorization` |
50+
4251
## False-Positive Taxonomy
4352

4453
The taxonomy names benign or ambiguous explanations a reviewer should consider before interpreting a finding. It is not an allow-list, suppression policy, or automatic disposition.

src/detector.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ Finding make_brute_force_finding(const std::string& ip,
8888
finding.first_seen = first_seen;
8989
finding.last_seen = last_seen;
9090
finding.evidence_event_ids = std::move(evidence_event_ids);
91+
finding.verdict_boundary = default_verdict_boundary(finding.type);
9192
finding.summary = std::to_string(count) + " failed SSH attempts from " + ip
9293
+ " within " + std::to_string(window.count()) + " minutes.";
9394
return finding;
@@ -114,6 +115,7 @@ Finding make_multi_user_finding(const std::string& ip,
114115
finding.first_seen = first_seen;
115116
finding.last_seen = last_seen;
116117
finding.evidence_event_ids = std::move(evidence_event_ids);
118+
finding.verdict_boundary = default_verdict_boundary(finding.type);
117119
finding.usernames = std::move(usernames);
118120
finding.summary = ip + " targeted " + std::to_string(finding.usernames.size())
119121
+ " usernames within " + std::to_string(window.count()) + " minutes.";
@@ -139,6 +141,7 @@ Finding make_sudo_finding(const std::string& user,
139141
finding.first_seen = first_seen;
140142
finding.last_seen = last_seen;
141143
finding.evidence_event_ids = std::move(evidence_event_ids);
144+
finding.verdict_boundary = default_verdict_boundary(finding.type);
142145
finding.summary = user + " ran " + std::to_string(count)
143146
+ " sudo commands within " + std::to_string(window.count()) + " minutes.";
144147
return finding;
@@ -306,6 +309,18 @@ std::string to_string(FindingType type) {
306309
}
307310
}
308311

312+
std::string default_verdict_boundary(FindingType type) {
313+
switch (type) {
314+
case FindingType::BruteForce:
315+
return "triage_signal_not_compromise_or_attribution";
316+
case FindingType::MultiUserProbing:
317+
return "triage_signal_not_intent_or_attribution";
318+
case FindingType::SudoBurst:
319+
default:
320+
return "triage_signal_not_maliciousness_or_authorization";
321+
}
322+
}
323+
309324
Detector::Detector(DetectorConfig config)
310325
: config_(config) {}
311326

src/detector.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@ struct Finding {
3939
std::chrono::sys_seconds first_seen{};
4040
std::chrono::sys_seconds last_seen{};
4141
std::vector<std::string> evidence_event_ids;
42+
std::string verdict_boundary;
4243
std::vector<std::string> usernames;
4344
std::string summary;
4445
};
4546

4647
std::string to_string(FindingType type);
48+
std::string default_verdict_boundary(FindingType type);
4749

4850
class Detector {
4951
public:

src/report.cpp

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,13 @@ std::size_t finding_observed_count(const Finding& finding) {
312312
return finding.event_count;
313313
}
314314

315+
std::string finding_verdict_boundary(const Finding& finding) {
316+
if (!finding.verdict_boundary.empty()) {
317+
return finding.verdict_boundary;
318+
}
319+
return default_verdict_boundary(finding.type);
320+
}
321+
315322
void write_json_string_array(std::ostream& output, const std::vector<std::string>& values) {
316323
output << '[';
317324
for (std::size_t index = 0; index < values.size(); ++index) {
@@ -637,8 +644,8 @@ std::string render_json_report(const ReportData& data) {
637644

638645
output << "{\n";
639646
output << " \"tool\": \"LogLens\",\n";
640-
output << " \"schema\": \"loglens.report.v1\",\n";
641-
output << " \"schema_version\": 1,\n";
647+
output << " \"schema\": \"loglens.report.v2\",\n";
648+
output << " \"schema_version\": 2,\n";
642649
output << " \"input\": \"" << escape_json(data.input_path.generic_string()) << "\",\n";
643650
output << " \"input_mode\": \"" << to_string(data.parse_metadata.input_mode) << "\",\n";
644651
if (data.parse_metadata.assume_year.has_value()) {
@@ -718,6 +725,7 @@ std::string render_json_report(const ReportData& data) {
718725
output << " \"evidence_event_ids\": ";
719726
write_json_string_array(output, finding.evidence_event_ids);
720727
output << ",\n";
728+
output << " \"verdict_boundary\": \"" << escape_json(finding_verdict_boundary(finding)) << "\",\n";
721729
output << " \"usernames\": ";
722730
write_json_string_array(output, finding.usernames);
723731
output << ",\n";

tests/fixtures/report_contracts/journalctl_short_full/report.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"tool": "LogLens",
3-
"schema": "loglens.report.v1",
4-
"schema_version": 1,
3+
"schema": "loglens.report.v2",
4+
"schema_version": 2,
55
"input": "tests/fixtures/report_contracts/journalctl_short_full/input.log",
66
"input_mode": "journalctl_short_full",
77
"timezone_present": true,
@@ -45,6 +45,7 @@
4545
"window_start": "2026-03-10 08:11:22",
4646
"window_end": "2026-03-10 08:18:05",
4747
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
48+
"verdict_boundary": "triage_signal_not_compromise_or_attribution",
4849
"usernames": [],
4950
"summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes."
5051
},
@@ -60,6 +61,7 @@
6061
"window_start": "2026-03-10 08:11:22",
6162
"window_end": "2026-03-10 08:18:05",
6263
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
64+
"verdict_boundary": "triage_signal_not_intent_or_attribution",
6365
"usernames": ["admin", "deploy", "guest", "root", "test"],
6466
"summary": "203.0.113.10 targeted 5 usernames within 15 minutes."
6567
},
@@ -75,6 +77,7 @@
7577
"window_start": "2026-03-10 08:21:00",
7678
"window_end": "2026-03-10 08:24:15",
7779
"evidence_event_ids": ["line:7", "line:8", "line:9"],
80+
"verdict_boundary": "triage_signal_not_maliciousness_or_authorization",
7881
"usernames": [],
7982
"summary": "alice ran 3 sudo commands within 5 minutes."
8083
}

tests/fixtures/report_contracts/multi_host_journalctl_short_full/report.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"tool": "LogLens",
3-
"schema": "loglens.report.v1",
4-
"schema_version": 1,
3+
"schema": "loglens.report.v2",
4+
"schema_version": 2,
55
"input": "tests/fixtures/report_contracts/multi_host_journalctl_short_full/input.log",
66
"input_mode": "journalctl_short_full",
77
"timezone_present": true,
@@ -73,6 +73,7 @@
7373
"window_start": "2026-03-11 09:00:00",
7474
"window_end": "2026-03-11 09:04:05",
7575
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
76+
"verdict_boundary": "triage_signal_not_compromise_or_attribution",
7677
"usernames": [],
7778
"summary": "5 failed SSH attempts from 203.0.113.10 within 10 minutes."
7879
},
@@ -88,6 +89,7 @@
8889
"window_start": "2026-03-11 09:00:00",
8990
"window_end": "2026-03-11 09:04:05",
9091
"evidence_event_ids": ["line:1", "line:2", "line:3", "line:4", "line:5"],
92+
"verdict_boundary": "triage_signal_not_intent_or_attribution",
9193
"usernames": ["admin", "deploy", "guest", "root", "test"],
9294
"summary": "203.0.113.10 targeted 5 usernames within 15 minutes."
9395
},
@@ -103,6 +105,7 @@
103105
"window_start": "2026-03-11 09:11:00",
104106
"window_end": "2026-03-11 09:14:15",
105107
"evidence_event_ids": ["line:9", "line:10", "line:13"],
108+
"verdict_boundary": "triage_signal_not_maliciousness_or_authorization",
106109
"usernames": [],
107110
"summary": "alice ran 3 sudo commands within 5 minutes."
108111
}

0 commit comments

Comments
 (0)