Skip to content

Commit 29f05e2

Browse files
authored
Merge pull request #60 from stacknil/stacknil/loglens-reviewer-config-contracts
Lock sample config and reviewer evidence contracts
2 parents 005c09e + 91fe10a commit 29f05e2

7 files changed

Lines changed: 146 additions & 4 deletions

File tree

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ It parses `auth.log` / `secure`-style syslog input and `journalctl --output=shor
1111

1212
LogLens is an MVP / early release. The repository is stable enough for public review, local experimentation, and extension, but the parser and detection coverage are intentionally narrow.
1313

14-
Reviewing the project quickly? Start with [`docs/reviewer-path.md`](./docs/reviewer-path.md) and [`docs/reviewer-brief.md`](./docs/reviewer-brief.md).
14+
Reviewing the project quickly? Start with [`docs/reviewer-path.md`](./docs/reviewer-path.md) and [`docs/reviewer-brief.md`](./docs/reviewer-brief.md). For detection reasoning, read the forensic-style [`Linux auth brute-force case study`](./docs/case-study-linux-auth-bruteforce.md) and the [`rule catalog`](./docs/rule-catalog.md).
1515

1616
## Why This Project Exists
1717

@@ -90,7 +90,7 @@ Common unsupported-pattern buckets include `sshd_connection_closed_preauth`,
9090
`pam_faillock_account_locked`, and `pam_unix_session_closed`. These buckets keep
9191
non-finding evidence reviewable without counting it as detector evidence.
9292

93-
For the parser behavior contract, supported modes, and fixture map, see [`docs/parser-contract.md`](./docs/parser-contract.md). For the deliberately noisy parser-coverage sample, see [`docs/parser-coverage-notes.md`](./docs/parser-coverage-notes.md).
93+
For rule-by-rule semantics and signal boundaries, see [`docs/rule-catalog.md`](./docs/rule-catalog.md). For a forensic-style evidence walkthrough, see [`docs/case-study-linux-auth-bruteforce.md`](./docs/case-study-linux-auth-bruteforce.md). For the parser behavior contract, supported modes, and fixture map, see [`docs/parser-contract.md`](./docs/parser-contract.md). For the deliberately noisy parser-coverage sample, see [`docs/parser-coverage-notes.md`](./docs/parser-coverage-notes.md).
9494

9595
LogLens does not currently detect:
9696

@@ -108,6 +108,12 @@ cmake --build build
108108
ctest --test-dir build --output-on-failure
109109
```
110110

111+
For Visual Studio or other multi-config generators, pass the built configuration to CTest:
112+
113+
```bash
114+
ctest --test-dir build -C Debug --output-on-failure
115+
```
116+
111117
For fresh-machine setup and repeatable local presets, see [`docs/dev-setup.md`](./docs/dev-setup.md).
112118

113119
## Run
@@ -213,6 +219,8 @@ The config file schema is intentionally small and strict:
213219

214220
This mapping lets LogLens normalize parsed events into detection signals before applying brute-force or multi-user rules. By default, `pam_auth_failure` is treated as lower-confidence attempt evidence and does not count as a terminal authentication failure unless the config explicitly upgrades it. The `ssh_failed_keyboard_interactive` and `ssh_max_auth_tries` mapping keys are optional in older configs and default to terminal failure evidence.
215221

222+
The checked-in [`assets/sample_config.json`](./assets/sample_config.json) is tested as a runnable default-equivalent config fixture. If default detector thresholds or signal mappings change, update that file and the related tests together.
223+
216224
Timestamp handling is now explicit:
217225

218226
- `--mode syslog`, `--mode syslog-legacy`, or `input_mode: syslog_legacy` requires `--year` or `timestamp.assume_year`

docs/dev-setup.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ cmake --build build
4444
ctest --test-dir build --output-on-failure
4545
```
4646

47+
For Visual Studio or other multi-config generators, run the test step with the
48+
built configuration, for example `ctest --test-dir build -C Debug --output-on-failure`.
49+
4750
## Windows Notes
4851

4952
- Run from a Developer PowerShell for Visual Studio 2022, an x64 Native Tools prompt, or another shell where the MSVC toolchain is already available.
@@ -63,5 +66,5 @@ sudo apt install cmake g++ make
6366
## Expected Local Outputs
6467

6568
- Build directories under `build/dev-debug` or `build/ci-release`
66-
- Test runs for `parser`, `detector`, and `cli`
69+
- Test runs for `parser`, `detector`, `report`, `cli`, and `report_contracts`
6770
- `compile_commands.json` in the debug build directory when the selected generator supports it

docs/reviewer-brief.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Linux auth logs are noisy, format-sensitive, and easy to parse incorrectly. Revi
1212

1313
- Reproducible command: `./build/loglens --mode syslog --year 2026 ./assets/sample_auth.log ./out`
1414
- Deterministic outputs: `report.md`, `report.json`, optional `findings.csv`, optional `warnings.csv`, and parser coverage telemetry.
15+
- Detection reasoning: [`docs/rule-catalog.md`](./rule-catalog.md) documents rule inputs and boundaries; [`docs/case-study-linux-auth-bruteforce.md`](./case-study-linux-auth-bruteforce.md) traces a sanitized evidence set from raw lines to findings and warnings.
1516
- Tests / CI: CTest coverage plus GitHub Actions CI on Ubuntu and Windows; CodeQL is required on protected main.
1617
- Release evidence: changelog, release process docs, versioned release notes, and GitHub release artifacts.
1718
- Non-goals: live collection, SIEM replacement, cross-host correlation, exploitation, credential attack automation, or incident verdicts.

docs/reviewer-path.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
This path is for reviewers who want to understand LogLens quickly without reading the whole repository first.
44

5+
## First choose the review question
6+
7+
| Review question | Start here | Good stopping point |
8+
| --- | --- | --- |
9+
| What is LogLens? | [`README.md`](../README.md) and [`docs/reviewer-brief.md`](./reviewer-brief.md) | Can state scope, supported inputs, outputs, and non-goals |
10+
| What log formats are supported? | [`docs/parser-contract.md`](./parser-contract.md) | Can name `syslog_legacy` and `journalctl_short_full` behavior |
11+
| What artifacts does it produce? | [`docs/report-artifacts.md`](./report-artifacts.md) and report-contract fixtures | Can inspect Markdown, JSON, and optional CSV outputs |
12+
| How do rules use evidence? | [`docs/rule-catalog.md`](./rule-catalog.md) | Can explain grouping keys, windows, thresholds, and unsupported-evidence boundaries |
13+
| Can the parser behavior be trusted? | Parser contract, fixture matrix, and parser coverage fields | Can see known, unknown, and malformed line handling |
14+
| How should a finding be interpreted? | [`docs/case-study-linux-auth-bruteforce.md`](./case-study-linux-auth-bruteforce.md) | Can trace raw evidence to normalized events, findings, warnings, and non-goals |
15+
516
## 30-second orientation
617

718
Read:
@@ -30,6 +41,16 @@ Inspect:
3041
- [`tests/fixtures/report_contracts/syslog_legacy/report.json`](../tests/fixtures/report_contracts/syslog_legacy/report.json)
3142
- [`docs/report-artifacts.md`](./report-artifacts.md)
3243
- [`docs/parser-contract.md`](./parser-contract.md)
44+
- [`docs/rule-catalog.md`](./rule-catalog.md)
45+
- [`docs/case-study-linux-auth-bruteforce.md`](./case-study-linux-auth-bruteforce.md)
46+
47+
Look for the evidence route:
48+
49+
- raw log line
50+
- normalized event
51+
- signal mapping boundary
52+
- rule grouping, window, and threshold
53+
- report finding or parser warning
3354

3455
Look for parser coverage fields:
3556

@@ -41,7 +62,7 @@ Look for parser coverage fields:
4162
- `parse_success_rate`
4263
- `top_unknown_patterns`
4364

44-
Good stopping point: the reviewer can explain what LogLens parses, what it reports, and how unsupported lines remain visible.
65+
Good stopping point: the reviewer can explain what LogLens parses, how rules count supported evidence, what the reports contain, and how unsupported lines remain visible without becoming findings.
4566

4667
## 15-minute local check
4768

@@ -54,6 +75,9 @@ ctest --test-dir build --output-on-failure
5475
./build/loglens --mode syslog --year 2026 ./assets/sample_auth.log ./out
5576
```
5677

78+
For Visual Studio or other multi-config generators, use
79+
`ctest --test-dir build -C Debug --output-on-failure` for the test step.
80+
5781
Then inspect:
5882

5983
- `out/report.md`

docs/rule-catalog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Metadata equivalent:
2424
- Rule names are stable report values.
2525
- Windows and thresholds are configurable through `config.json`.
2626
- Default values below match the built-in detector configuration.
27+
- The checked-in `assets/sample_config.json` is a tested default-equivalent fixture.
2728

2829
## Brute Force
2930

tests/test_cli.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ int main(int argc, char* argv[]) {
225225
+ " " + quote_argument(config_run_out))
226226
.c_str());
227227
expect(config_run_exit == 0, "expected sample config run to succeed");
228+
expect_report_core_fields(
229+
read_file(config_run_out / "report.md"),
230+
read_file(config_run_out / "report.json"),
231+
"syslog_legacy",
232+
true,
233+
false);
228234

229235
const auto journalctl_out = output_dir / "journalctl_cli";
230236
std::filesystem::create_directories(journalctl_out);

tests/test_detector.cpp

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include <stdexcept>
1111
#include <string>
1212
#include <string_view>
13+
#include <vector>
1314

1415
namespace {
1516

@@ -49,6 +50,31 @@ std::vector<loglens::Event> parse_events(loglens::ParserConfig config, std::stri
4950
return parser.parse_stream(input).events;
5051
}
5152

53+
std::filesystem::path repo_root() {
54+
const std::filesystem::path source_path{__FILE__};
55+
std::vector<std::filesystem::path> candidates;
56+
57+
if (source_path.is_absolute()) {
58+
candidates.push_back(source_path);
59+
} else {
60+
const auto cwd = std::filesystem::current_path();
61+
candidates.push_back(cwd / source_path);
62+
candidates.push_back(cwd.parent_path() / source_path);
63+
}
64+
65+
for (const auto& candidate : candidates) {
66+
if (std::filesystem::exists(candidate)) {
67+
return candidate.parent_path().parent_path();
68+
}
69+
}
70+
71+
throw std::runtime_error("unable to resolve repository root from test source path");
72+
}
73+
74+
std::filesystem::path asset_path(std::string_view filename) {
75+
return repo_root() / "assets" / std::string(filename);
76+
}
77+
5278
loglens::ParserConfig make_syslog_config() {
5379
return loglens::ParserConfig{
5480
loglens::InputMode::SyslogLegacy,
@@ -121,6 +147,54 @@ std::vector<loglens::Event> build_sudo_burst_preservation_events() {
121147
"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");
122148
}
123149

150+
void expect_same_rule_threshold(const loglens::RuleThreshold& actual,
151+
const loglens::RuleThreshold& expected,
152+
const std::string& rule_name) {
153+
expect(actual.threshold == expected.threshold, "expected " + rule_name + " threshold to match default");
154+
expect(actual.window == expected.window, "expected " + rule_name + " window to match default");
155+
}
156+
157+
void expect_same_auth_signal_behavior(const loglens::AuthSignalBehavior& actual,
158+
const loglens::AuthSignalBehavior& expected,
159+
const std::string& signal_name) {
160+
expect(actual.counts_as_attempt_evidence == expected.counts_as_attempt_evidence,
161+
"expected " + signal_name + " attempt-evidence mapping to match default");
162+
expect(actual.counts_as_terminal_auth_failure == expected.counts_as_terminal_auth_failure,
163+
"expected " + signal_name + " terminal-failure mapping to match default");
164+
}
165+
166+
void expect_same_detector_config(const loglens::DetectorConfig& actual,
167+
const loglens::DetectorConfig& expected) {
168+
expect_same_rule_threshold(actual.brute_force, expected.brute_force, "brute_force");
169+
expect_same_rule_threshold(actual.multi_user_probing, expected.multi_user_probing, "multi_user_probing");
170+
expect_same_rule_threshold(actual.sudo_burst, expected.sudo_burst, "sudo_burst");
171+
172+
expect_same_auth_signal_behavior(
173+
actual.auth_signal_mappings.ssh_failed_password,
174+
expected.auth_signal_mappings.ssh_failed_password,
175+
"ssh_failed_password");
176+
expect_same_auth_signal_behavior(
177+
actual.auth_signal_mappings.ssh_invalid_user,
178+
expected.auth_signal_mappings.ssh_invalid_user,
179+
"ssh_invalid_user");
180+
expect_same_auth_signal_behavior(
181+
actual.auth_signal_mappings.ssh_failed_publickey,
182+
expected.auth_signal_mappings.ssh_failed_publickey,
183+
"ssh_failed_publickey");
184+
expect_same_auth_signal_behavior(
185+
actual.auth_signal_mappings.ssh_failed_keyboard_interactive,
186+
expected.auth_signal_mappings.ssh_failed_keyboard_interactive,
187+
"ssh_failed_keyboard_interactive");
188+
expect_same_auth_signal_behavior(
189+
actual.auth_signal_mappings.ssh_max_auth_tries,
190+
expected.auth_signal_mappings.ssh_max_auth_tries,
191+
"ssh_max_auth_tries");
192+
expect_same_auth_signal_behavior(
193+
actual.auth_signal_mappings.pam_auth_failure,
194+
expected.auth_signal_mappings.pam_auth_failure,
195+
"pam_auth_failure");
196+
}
197+
124198
void test_default_thresholds() {
125199
const auto events = build_events();
126200
const loglens::Detector detector;
@@ -341,6 +415,30 @@ void test_load_valid_config() {
341415
expect(findings.size() == 3, "expected loaded config to preserve default findings");
342416
}
343417

418+
void test_sample_config_matches_default_detector_contract() {
419+
const auto config = loglens::load_app_config(asset_path("sample_config.json"));
420+
expect(config.input_mode == loglens::InputMode::SyslogLegacy,
421+
"expected sample config to use syslog legacy input");
422+
expect(config.timestamp.assume_year == 2026,
423+
"expected sample config to provide the sample syslog year");
424+
expect_same_detector_config(config.detector, loglens::DetectorConfig{});
425+
426+
const auto events = build_events();
427+
const loglens::Detector default_detector;
428+
const loglens::Detector sample_config_detector(config.detector);
429+
const auto default_findings = default_detector.analyze(events);
430+
const auto sample_config_findings = sample_config_detector.analyze(events);
431+
432+
expect(sample_config_findings.size() == default_findings.size(),
433+
"expected sample config to preserve default finding count");
434+
expect(find_finding(sample_config_findings, loglens::FindingType::BruteForce, "203.0.113.10") != nullptr,
435+
"expected sample config to preserve brute-force finding");
436+
expect(find_finding(sample_config_findings, loglens::FindingType::MultiUserProbing, "203.0.113.10") != nullptr,
437+
"expected sample config to preserve multi-user finding");
438+
expect(find_finding(sample_config_findings, loglens::FindingType::SudoBurst, "alice") != nullptr,
439+
"expected sample config to preserve sudo-burst finding");
440+
}
441+
344442
void test_reject_invalid_config() {
345443
const auto temp_path = std::filesystem::current_path() / "invalid_config_test.json";
346444
{
@@ -389,6 +487,7 @@ int main() {
389487
test_pam_auth_failure_does_not_trigger_bruteforce_by_default();
390488
test_equivalent_attack_scenario_yields_same_finding_count_across_modes();
391489
test_load_valid_config();
490+
test_sample_config_matches_default_detector_contract();
392491
test_reject_invalid_config();
393492
return 0;
394493
}

0 commit comments

Comments
 (0)