Skip to content

Commit e0cc39a

Browse files
authored
Merge pull request #93 from stacknil/stacknil/parser-property-fuzz-tests
test(parser): add property and fuzz coverage
2 parents 4ad9532 + 3a29e1b commit e0cc39a

16 files changed

Lines changed: 444 additions & 13 deletions

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,32 @@ jobs:
3030

3131
- name: Test
3232
run: ctest --test-dir build --build-config Release --output-on-failure
33+
34+
fuzz-smoke:
35+
name: Parser fuzz smoke
36+
runs-on: ubuntu-latest
37+
38+
steps:
39+
- name: Checkout
40+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
41+
42+
- name: Configure fuzz target
43+
env:
44+
CC: clang
45+
CXX: clang++
46+
run: >-
47+
cmake -S . -B build-fuzz
48+
-D CMAKE_BUILD_TYPE=RelWithDebInfo
49+
-D BUILD_TESTING=OFF
50+
-D LOGLENS_BUILD_FUZZERS=ON
51+
52+
- name: Build fuzz target
53+
run: cmake --build build-fuzz --target fuzz_parser --config RelWithDebInfo
54+
55+
- name: Run bounded parser fuzz smoke
56+
run: >-
57+
./build-fuzz/fuzz_parser
58+
-runs=2000
59+
-max_len=1024
60+
-timeout=2
61+
tests/fuzz/corpus/parser

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ All notable user-visible changes should be recorded here.
1414
input order and inclusive window-boundary behavior.
1515
- Added parser regression coverage for malformed source-IP token
1616
classification.
17+
- Added deterministic parser property tests for registry-order independence,
18+
generated malformed tokens, failure taxonomy stability, and arbitrary-byte
19+
result invariants.
20+
- Added an optional Clang libFuzzer parser target with a sanitized seed corpus
21+
and bounded Ubuntu CI smoke campaign.
1722

1823
### Changed
1924

CMakeLists.txt

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ set(CMAKE_CXX_STANDARD 20)
66
set(CMAKE_CXX_STANDARD_REQUIRED ON)
77
set(CMAKE_CXX_EXTENSIONS OFF)
88

9-
add_library(loglens_lib
9+
option(LOGLENS_BUILD_FUZZERS "Build Clang libFuzzer targets" OFF)
10+
11+
set(LOGLENS_LIBRARY_SOURCES
1012
src/config.cpp
1113
src/parser.cpp
1214
src/parser/failure_classifier.cpp
@@ -23,6 +25,8 @@ add_library(loglens_lib
2325
src/report.cpp
2426
)
2527

28+
add_library(loglens_lib ${LOGLENS_LIBRARY_SOURCES})
29+
2630
target_include_directories(loglens_lib
2731
PUBLIC
2832
"${CMAKE_CURRENT_SOURCE_DIR}/src"
@@ -38,6 +42,10 @@ if(BUILD_TESTING)
3842
target_link_libraries(test_parser PRIVATE loglens_lib)
3943
add_test(NAME parser COMMAND test_parser)
4044

45+
add_executable(test_parser_properties tests/test_parser_properties.cpp)
46+
target_link_libraries(test_parser_properties PRIVATE loglens_lib)
47+
add_test(NAME parser_properties COMMAND test_parser_properties)
48+
4149
add_executable(test_detector tests/test_detector.cpp)
4250
target_link_libraries(test_detector PRIVATE loglens_lib)
4351
add_test(NAME detector COMMAND test_detector)
@@ -65,3 +73,28 @@ if(BUILD_TESTING)
6573
${CMAKE_CURRENT_BINARY_DIR}/report_contract_output
6674
)
6775
endif()
76+
77+
if(LOGLENS_BUILD_FUZZERS)
78+
if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
79+
message(FATAL_ERROR "LOGLENS_BUILD_FUZZERS requires Clang with libFuzzer support")
80+
endif()
81+
82+
add_library(loglens_fuzz_lib STATIC ${LOGLENS_LIBRARY_SOURCES})
83+
target_include_directories(loglens_fuzz_lib PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")
84+
target_compile_options(
85+
loglens_fuzz_lib
86+
PRIVATE
87+
-fsanitize=fuzzer-no-link,address,undefined
88+
-fno-omit-frame-pointer
89+
)
90+
91+
add_executable(fuzz_parser fuzz/parser_fuzz.cpp)
92+
target_link_libraries(fuzz_parser PRIVATE loglens_fuzz_lib)
93+
target_compile_options(
94+
fuzz_parser
95+
PRIVATE
96+
-fsanitize=fuzzer,address,undefined
97+
-fno-omit-frame-pointer
98+
)
99+
target_link_options(fuzz_parser PRIVATE -fsanitize=fuzzer,address,undefined)
100+
endif()

docs/parser-contract.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ Parsed successes and audit-only events remain reportable but do not count as bru
107107
| Artifact | What it proves |
108108
| --- | --- |
109109
| [`tests/test_parser.cpp`](../tests/test_parser.cpp) | Unit-level parser expectations, malformed-line behavior, mode aliases, fixture-matrix counts, and unknown-pattern buckets |
110+
| [`tests/test_parser_properties.cpp`](../tests/test_parser_properties.cpp) | Deterministic generated checks for handler-registry order independence, malformed source tokens, failure taxonomy stability, and arbitrary-byte result invariants |
111+
| [`tests/fuzz/README.md`](../tests/fuzz/README.md) and [`tests/fuzz/corpus/parser`](../tests/fuzz/corpus/parser) | Optional Clang libFuzzer harness instructions plus a sanitized parser seed corpus used by the bounded CI fuzz smoke campaign |
110112
| [`tests/test_detector.cpp`](../tests/test_detector.cpp) | Detection signal mapping and default counting behavior after parsing |
111113
| [`assets/parser_fixture_matrix_syslog.log`](../assets/parser_fixture_matrix_syslog.log) | Syslog known/unknown parser matrix |
112114
| [`assets/parser_fixture_matrix_journalctl_short_full.log`](../assets/parser_fixture_matrix_journalctl_short_full.log) | Journalctl short-full known/unknown parser matrix |

docs/quality-gates.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The main review principle is:
1313
| --- | --- | --- | --- |
1414
| Supported input formats are explicit | [`parser-contract.md`](./parser-contract.md), [`parser-conformance-matrix.md`](./parser-conformance-matrix.md) | `ctest --test-dir build --output-on-failure` through `test_parser`; fixture anchors in `assets/parser_fixture_matrix_syslog.log` and `assets/parser_fixture_matrix_journalctl_short_full.log` | Reviewer can name the two supported formats and see known/unknown line behavior in fixtures |
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 |
16+
| Parser handler dispatch remains robust | [`parser-contract.md`](./parser-contract.md), [`tests/test_parser_properties.cpp`](../tests/test_parser_properties.cpp), [`tests/fuzz/README.md`](../tests/fuzz/README.md) | `test_parser_properties` checks all handler-order permutations and generated failure invariants; Ubuntu CI runs a bounded Clang libFuzzer smoke corpus | Reviewer can see that registry order does not change results and malformed or arbitrary bytes remain bounded by parser result invariants |
1617
| 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 |
1718
| 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 |
1819
| 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 `finding_id`, `episode_index`, `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 |
@@ -31,6 +32,8 @@ Use the smallest command set that answers the review question:
3132
| Build and unit/regression tests | `cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure` |
3233
| Multi-config local test run | `ctest --test-dir build -C Debug --output-on-failure` |
3334
| Report-contract snapshot verification | `ctest --test-dir build -C Debug -R report_contracts --output-on-failure` |
35+
| Parser property checks | `ctest --test-dir build -C Debug -R parser_properties --output-on-failure` |
36+
| Bounded parser fuzz smoke (Clang) | See [`tests/fuzz/README.md`](../tests/fuzz/README.md) |
3437
| Performance envelope reproduction | `pwsh -File scripts/benchmark-performance-envelope.ps1` |
3538
| Fast performance smoke check | `pwsh -File scripts/benchmark-performance-envelope.ps1 -LineCounts 1000 -Runs 1 -WarmupRuns 0 -SkipBuild` |
3639

fuzz/parser_fuzz.cpp

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#include "parser.hpp"
2+
3+
#include <array>
4+
#include <cstddef>
5+
#include <cstdint>
6+
#include <cstdlib>
7+
#include <optional>
8+
#include <string>
9+
10+
namespace {
11+
12+
void enforce_result_invariants(const std::optional<loglens::Event>& event,
13+
const std::string& reason) {
14+
if (event.has_value()) {
15+
if (event->event_type == loglens::EventType::Unknown || event->program.empty()) {
16+
std::abort();
17+
}
18+
return;
19+
}
20+
21+
if (reason.empty()) {
22+
std::abort();
23+
}
24+
}
25+
26+
} // namespace
27+
28+
extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) {
29+
const std::string line(reinterpret_cast<const char*>(data), size);
30+
const std::array<loglens::AuthLogParser, 2> parsers{{
31+
loglens::AuthLogParser(loglens::ParserConfig{
32+
loglens::InputMode::SyslogLegacy,
33+
2026}),
34+
loglens::AuthLogParser(loglens::ParserConfig{
35+
loglens::InputMode::JournalctlShortFull,
36+
std::nullopt}),
37+
}};
38+
39+
for (const auto& parser : parsers) {
40+
std::string reason;
41+
auto category = loglens::ParserFailureCategory::KnownProgramUnknownMessage;
42+
const auto event = parser.parse_line(line, 1, &reason, &category);
43+
enforce_result_invariants(event, reason);
44+
}
45+
46+
return 0;
47+
}

src/parser/program_dispatch.cpp

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,10 @@
77
#include "parser/sudo_handlers.hpp"
88

99
#include <array>
10-
#include <string_view>
1110

1211
namespace loglens::parser_internal {
1312
namespace {
1413

15-
using ProgramMatcher = bool (*)(std::string_view program);
16-
using ProgramHandler = HandlerResult (*)(const Event& source);
17-
18-
struct HandlerRegistration {
19-
ProgramMatcher matches;
20-
ProgramHandler handle;
21-
};
22-
2314
bool is_sshd(std::string_view program) {
2415
return program == "sshd";
2516
}
@@ -44,7 +35,7 @@ bool is_su(std::string_view program) {
4435
return program == "su";
4536
}
4637

47-
constexpr std::array<HandlerRegistration, 6> handler_registry{{
38+
constexpr std::array<ProgramHandlerRegistration, 6> handler_registry{{
4839
{is_sshd, handle_sshd_event},
4940
{is_pam_unix, handle_pam_unix_event},
5041
{is_pam_faillock, handle_pam_faillock_event},
@@ -55,8 +46,13 @@ constexpr std::array<HandlerRegistration, 6> handler_registry{{
5546

5647
} // namespace
5748

58-
HandlerResult dispatch_program(const Event& source) {
59-
for (const auto& registration : handler_registry) {
49+
std::span<const ProgramHandlerRegistration> program_handler_registry() {
50+
return handler_registry;
51+
}
52+
53+
HandlerResult dispatch_program(const Event& source,
54+
std::span<const ProgramHandlerRegistration> registry) {
55+
for (const auto& registration : registry) {
6056
if (registration.matches(source.program)) {
6157
return registration.handle(source);
6258
}
@@ -65,4 +61,8 @@ HandlerResult dispatch_program(const Event& source) {
6561
return classify_unrecognized_event(source);
6662
}
6763

64+
HandlerResult dispatch_program(const Event& source) {
65+
return dispatch_program(source, program_handler_registry());
66+
}
67+
6868
} // namespace loglens::parser_internal

src/parser/program_dispatch.hpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,22 @@
22

33
#include "parser/handler_result.hpp"
44

5+
#include <span>
6+
#include <string_view>
7+
58
namespace loglens::parser_internal {
69

10+
using ProgramMatcher = bool (*)(std::string_view program);
11+
using ProgramHandler = HandlerResult (*)(const Event& source);
12+
13+
struct ProgramHandlerRegistration {
14+
ProgramMatcher matches;
15+
ProgramHandler handle;
16+
};
17+
18+
std::span<const ProgramHandlerRegistration> program_handler_registry();
719
HandlerResult dispatch_program(const Event& source);
20+
HandlerResult dispatch_program(const Event& source,
21+
std::span<const ProgramHandlerRegistration> registry);
822

923
} // namespace loglens::parser_internal

tests/fuzz/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Parser fuzz harness
2+
3+
The parser fuzz target is optional and requires Clang with libFuzzer support.
4+
It exercises both supported input modes and treats these result invariants as
5+
crash conditions:
6+
7+
- every emitted event has a normalized event type and non-empty program
8+
- every rejected line has a non-empty parser failure reason
9+
- arbitrary input must not terminate the parser unexpectedly
10+
11+
Configure and run a bounded local campaign:
12+
13+
```bash
14+
CC=clang CXX=clang++ cmake -S . -B build-fuzz \
15+
-D CMAKE_BUILD_TYPE=RelWithDebInfo \
16+
-D BUILD_TESTING=OFF \
17+
-D LOGLENS_BUILD_FUZZERS=ON
18+
cmake --build build-fuzz --target fuzz_parser
19+
./build-fuzz/fuzz_parser -runs=2000 -max_len=1024 tests/fuzz/corpus/parser
20+
```
21+
22+
The checked-in corpus is sanitized and intentionally small. CI uses it only as
23+
a bounded smoke campaign; longer local campaigns can reuse the same target.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Mon 2026-03-10 08:40:03 UTC example-host sshd[4403]: Invalid user user-d from 203.0.113.62 port 50102

0 commit comments

Comments
 (0)