diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c73ab3..f901e0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: -D LOGLENS_WARNINGS_AS_ERRORS=ON - name: Build fuzz target - run: cmake --build build-fuzz --target fuzz_parser --config RelWithDebInfo + run: cmake --build build-fuzz --target fuzz_parser fuzz_pipeline --config RelWithDebInfo - name: Run bounded parser fuzz smoke run: >- @@ -72,3 +72,14 @@ jobs: -max_len=1024 -timeout=2 tests/fuzz/corpus/parser + + - name: Generate synthetic sequence seeds + run: python3 scripts/generate-adversarial-corpus.py --output build-fuzz/corpus + + - name: Run bounded semantic pipeline fuzz smoke + env: + ASAN_OPTIONS: detect_leaks=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: >- + ./build-fuzz/fuzz_pipeline -seed=129130 -runs=2000 + -max_len=97 -timeout=3 -rss_limit_mb=1024 build-fuzz/corpus/pipeline diff --git a/CMakeLists.txt b/CMakeLists.txt index c4cbd25..63e1421 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,6 +73,11 @@ target_compile_definitions(loglens PRIVATE LOGLENS_VERSION="${LOGLENS_VERSION}") include(CTest) if(BUILD_TESTING) + add_executable(test_adversarial_semantics tests/test_adversarial_semantics.cpp) + target_link_libraries(test_adversarial_semantics PRIVATE loglens_lib) + loglens_enable_warnings(test_adversarial_semantics) + add_test(NAME adversarial_semantics COMMAND test_adversarial_semantics) + add_executable(test_parser tests/test_parser.cpp) target_link_libraries(test_parser PRIVATE loglens_lib) loglens_enable_warnings(test_parser) @@ -126,6 +131,13 @@ if(BUILD_TESTING) find_package(Python3 3.9 COMPONENTS Interpreter QUIET) if(Python3_Interpreter_FOUND) + add_executable(report_adversarial_driver tests/report_adversarial_driver.cpp) + target_link_libraries(report_adversarial_driver PRIVATE loglens_lib) + loglens_enable_warnings(report_adversarial_driver) + add_test(NAME adversarial_reports COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_adversarial_reports.py + $ + ${CMAKE_CURRENT_BINARY_DIR}/adversarial_report_output) add_test( NAME episode_candidate_research COMMAND @@ -176,4 +188,11 @@ if(LOGLENS_BUILD_FUZZERS) target_compile_options(fuzz_timestamp_day PRIVATE -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) target_link_options(fuzz_timestamp_day PRIVATE -fsanitize=fuzzer,address,undefined) + + add_executable(fuzz_pipeline fuzz/pipeline_fuzz.cpp) + target_link_libraries(fuzz_pipeline PRIVATE loglens_fuzz_lib) + loglens_enable_warnings(fuzz_pipeline) + target_compile_options(fuzz_pipeline PRIVATE + -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) + target_link_options(fuzz_pipeline PRIVATE -fsanitize=fuzzer,address,undefined) endif() diff --git a/docs/validation/adversarial-qualification.md b/docs/validation/adversarial-qualification.md new file mode 100644 index 0000000..b352287 --- /dev/null +++ b/docs/validation/adversarial-qualification.md @@ -0,0 +1,255 @@ +# Adversarial qualification design and progress + +Status: QUALIFICATION_READY for the bounded local campaign; SDK blocker resolved. +GCC, Clang ASan/UBSan and MSVC CTest each passed 11/11. This is +local qualification evidence, not a claim of complete memory safety. + +## Baseline and isolation + +The local qualification baseline is `6aef72ea5c6eb2657e6e01fe558d257e04997ca8`, +including the existing stream-completion correction. It descends from the local +main reference `2ab31a057206822fe0a526b849f0f836bd131a37`. The remote repository +was not queried: this campaign is offline. This choice exercises the existing +stream tests instead of rediscovering an already implemented transport fix. +No pre-existing checkout, research experiment, corpus or golden is changed. +All work remains local and uncommitted. + +## Failure model and oracle independence + +| Boundary | Failure / expected behavior | Observable oracle | +|---|---|---| +| Bytes to envelope | Truncation, NUL, control and non-UTF-8 bytes; malformed input must not crash | Bounded byte corpus, ASan/UBSan; explicit supported fixtures | +| Envelope to timestamp | Invalid calendar/clock/offset must reject; equivalent supported offsets denote one instant | Fixed epoch anchors and integer arithmetic, independent of production parsing | +| Envelope to dispatch | Program/PID variations must preserve supported auth semantics | Authored program/message/type/field table | +| Auth classification | Command payload must not become log metadata; unsupported evidence remains warning telemetry | Explicit expected classification and deliberate event/warning substitution controls | +| Event normalization | Malformed source tokens must not enter source-IP detector groups | Authored valid/invalid address cases; independently inspect emitted source fields | +| Event to signal | Audit-only events and successes do not become terminal failures; PAM remains lower-confidence | Explicit default eligibility table, not production mapping output | +| Signal to episodes | Inclusive time windows, threshold, grouping, highest-signal selection, unique line references | Small exhaustive window model and fixed separated-episode witnesses | +| Events to reports | Deterministic valid artifacts, complete counts, escaped fields and formula neutralization | Standard-library JSON/CSV consumers, decoded field expectations, repeated rendering | +| Stream completion | Transport error cannot return a successful partial parse | Existing injected streambuf faults and independent negative control | +| Resource use | Small adversarial records must not cause unbounded work | Fixed byte/line caps, process timeout/RSS observations, matched Release workloads | + +Crash freedom, parser classification, detector semantics, report validity and +resource behavior are separate claims. Sanitizer success does not establish +semantic correctness. Reporting determinism requires fixed explicit metadata +and fixed input-file contents, since host warning summaries reread the source. +Fuzz-generated paths must never select files on the host. + +## Executable invariant catalog + +1. Authored supported message families retain expected type, username and source. +2. Equivalent supported syslog/journalctl envelopes retain auth fields and instant. +3. Individual-line and complete-stream parsing agree, with explicit line identities. +4. Unsupported insertion cannot increase eligible signals or findings. +5. Unsupported evidence remains represented in warnings and category/bucket counts. +6. Blank insertion preserves auth semantics while adjusting source references/accounting. +7. Equivalent UTC and numeric offsets retain a fixed instant, including date rollover. +8. Invalid calendar/clock/offset tokens reject with timestamp taxonomy. +9. Malformed source tokens never become normalized trusted IP evidence. +10. Default signal eligibility matches the authored event-type table. +11. Threshold and inclusive time-window boundaries match an exhaustive small model. +12. Separated episodes retain distinct identities and exact evidence references. +13. Repeated rendering with fixed inputs produces identical artifacts. +14. JSON and CSV consumers recover the expected synthetic string values and structure. +15. CSV formula-leading fields remain neutralized after CSV decoding. +16. Global and host event counts conserve normalized input events. +17. Stream read failures remain failures, distinct from content warnings and EOF. +18. Sudo command payload text cannot reclassify an otherwise supported command record. + +Negative controls must corrupt observations at the boundary consumed by each +oracle. A wrapper passes only if its targeted checker rejects the corruption; +merely throwing an unrelated error is insufficient. Retain positive witnesses +and each deliberate RED result alongside the enclosing GREEN self-test. + +## Existing coverage and gaps + +The existing parser fuzzer checks event type/program presence and failure-reason +presence, not normalized field correctness or detector consequences. The day +fuzzer checks one March day token. Parser properties cover handler permutation, +selected malformed addresses and arbitrary-byte result shape. Stream tests +already cover injected transport faults and EOF exception masks. Detector tests +and report goldens cover selected episodes and output examples. New work must +measure added reachable semantics rather than claim novelty from more runs. + +## Progress journal + +- Baseline GCC 13.3 Release with warnings-as-errors: CTest 9/9. +- Baseline Clang 18.1.3 ASan/UBSan: CTest 9/9. +- Existing parser and day targets: 2,000 executions each, seed 129130; no + sanitizer finding in those bounded runs. Raw logs retained outside the source tree. +- Baseline CLI probes confirmed malformed IPv4/IPv6 admission, source-validation + versus extraction disagreement, marker-word username loss, and sudo command/PWD + text affecting classification. Retained original inputs and baseline outputs + precede corrections; focused regressions demonstrate RED then GREEN. +- An independent strict UTF-8 JSON consumer rejected six baseline report cases. + The correction passes all 17 authored cases with 51 artifact corruption controls. +- GCC warnings-as-errors and Clang ASan/UBSan CTest increased from 9 to 11 tests; + both passed. Existing report goldens and CLI contracts passed unchanged. +- Three serial 60-second fuzz budgets completed successfully; table below records + actual counts. A later strengthened sequence oracle replayed the retained corpus + without mutation and passed a 2,000-run smoke. +- A separate deterministic report byte-mutation campaign passed 273 cases under + ASan/UBSan, with 819 JSON/CSV corruption controls detected. This is boundary + testing through independent consumers, not coverage-guided report fuzzing. +- Six paired performance workloads completed with three trials per build. +- Initial MSVC 19.51.36256.0 configuration failed before project compilation: + Windows SDK `rc` and `mt` were unavailable. After explicit installation + authorization, SDK 10.0.26100.7705 and isolated Python test dependencies were + installed. The first Windows build succeeded but CTest exposed a CSV newline + preservation defect (10/11); the failure and original artifacts are retained. +- Only CSV output streams now use binary mode. Rebuilt MSVC Release with `/W4 + /WX` passed CTest 11/11, including the unchanged failing CRLF fixture. The + independent reviewer reproduced the 17-case/51-control GREEN consumer result + and found no conflict with the documented CSV contract. +- After the CSV correction, GCC and Clang sanitizer builds and full CTest passed + again. Both Windows and Linux ASan extended report runs passed all 273 cases + and detected 819 corruption controls each. Existing goldens remain unchanged. +- Final source validation rebuilt both Linux configurations and passed CTest + 11/11 in each; all three retained fuzz corpora replayed successfully without + mutation, followed by 2,000 executions per target. The final report boundary + campaign again passed 273 cases and 819 controls. Host/PID variation adds 18 + explicit envelope checks. No goldens or detector implementations changed. +- An independent read-only reviewer approved with limitations, reran the final + semantic executable successfully, and separately executed the earlier + 17-case/51-control report consumer. Their inspection of full-suite and extended + campaign logs is not claimed as independent execution of those campaigns. +- Performance artifact inspection confirmed equal parsed/warning/finding counts + across all baseline/candidate pairs; artifact byte sizes are recorded privately. +- Final pre-commit audit reproduced `PWD=/tmp/COMMAND=marker` without an actual + command field as a false sudo command; three copies produced a sudo burst. + Recognition now requires a field start (start of details or after the sudo + ` ; ` audit separator), never a substring inside a value. The permanent + negative regression was RED before the correction and GREEN afterward in both + input formats, alongside supported command and existing denial/failure cases. + GCC, Clang ASan/UBSan and MSVC CTest each passed 11/11 again; the pipeline + sanitizer smoke completed 2,000 runs. The exact three-line CLI reproduction + now yields zero events, three `sudo_other` warnings and zero findings. + +## Final audit boundary + +The original timed campaigns belong to checkpoint tree +`2cd0b9f551e3eaba5e5276d9b2c71f648f847bf6`. Subsequent changes strengthened +field/one-to-one finding checks, preserved bare-`::` rejection, added envelope +regressions, and extended report byte mutation. A subsequent Windows-only CSV +translation defect required a narrow file-output correction. Final rebuild, CTest, corpus +replay and bounded smoke evidence validate those later changes; the original +timed coverage counters must not be attributed to the final binary. The final +staged tree identity and exact local commands are in the private evidence manifest. + +Remaining limitations: no remote CI, no exhaustive IPv6 +language proof, no arbitrary detector configuration model, no native-filesystem +performance conclusion, and no coverage-guided report fuzzer. Report byte +mutation instead exercises actual serialization under sanitizers and independent +standard-library consumers. Existing injected stream faults cover transport +integrity; no new transport implementation was introduced. The independent +review found no unresolved material source defect. Windows tests use MSVC Release; +sanitizer evidence is Linux Clang only. Qualification is bounded to the recorded +inputs and configurations; it is neither complete memory-safety proof nor +publication authorization. All changes remain local and uncommitted. + +## Confirmed repairs and compatibility + +| Failure | Minimal correction | Regression boundary | +|---|---|---| +| Trailing-dot IPv4, incomplete/triple-colon IPv6 admitted | Decimal octets and IPv6 group/compression accounting | Explicit malformed rejection and valid token preservation | +| Source classifier validated a different token than handler emitted | Validate normalized source after successful dispatch | Adversarial username/source markers, exact emitted fields | +| Username `from` mistaken for delimiter | Extract source from the remainder after consuming username | Six SSH families, both envelopes | +| Sudo command/PWD value treated as authentication metadata | Match leading auth/denial metadata before command payload | Exact classification, no extra detector evidence | +| PWD value containing `COMMAND=` admitted as a command audit | Recognize `COMMAND=` only at audit-field starts | Missing-field negative and supported command positive witnesses through detector | +| Invalid UTF-8 emitted directly into JSON | Preserve valid Unicode and escape each malformed byte as `\u00XX` | Independent Python UTF-8/JSON decoder, byte mutation controls | +| Windows text output changes CSV field CRLF into CRCRLF | Open only CSV output files in binary mode | Existing CRLF field fixture fails before fix, passes after fix | + +No rule, threshold, signal eligibility, schema version, CSV serializer, golden, +or timestamp implementation changes are made. Parser corrections can change +counts for affected malformed/opaque inputs; that is the intended repair. +The existing rejection of bare `::` remains: this campaign does not broaden that +accepted-source policy. Textual IPv6 spellings are not canonicalized or regrouped. +JSON's malformed-byte representation is explicit and deterministic, but not an +injective encoding of arbitrary original bytes: an invalid byte and valid Unicode +can represent the same decoded character. Raw evidence remains the provenance +source; this correction does not promise byte recovery from JSON. CSV columns, +quoting and formula neutralization remain unchanged. Windows CSV record +separators change from CRLF to LF, and embedded field CR/LF bytes are preserved +exactly as rendered. The documented contract does not prescribe record terminator +bytes. JSON and Markdown retain their existing file modes. Reports never use fuzz +input as a filesystem path. + +Rejected alternatives: scan all message text for source/denial markers (opaque +payload confusion); use production detector output as expected values (tautology); +rewrite detector grouping (outside confirmed defects); replace report goldens +(would conceal compatibility failures). Rollback is reverting the local candidate +diff; no remote state or stored research data has been migrated. + +## Measured sanitizer campaigns + +Clang 18.1.3, ASan with leak detection, UBSan halt-on-error; seed 129130. +Serial workers, timeout 3 seconds/input, RSS limit 1,024 MiB; maximum inputs +4,096 / 32 / 97 bytes respectively. Counts include initial corpus processing. + +| Target | Executions | Actual reported seconds | Initial to final cov / ft | Peak RSS MiB | +|---|---:|---:|---|---:| +| parser | 1,092,204 | 62 | 551/715 to 1002/1807 | 490 | +| timestamp day | 14,875,968 | 61 | 296/361 to 319/432 | 540 | +| structured pipeline | 131,105 | 62 | 1979/4380 to 2585/12279 | 491 | + +These are libFuzzer instrumentation counters for each particular binary, not +source-line coverage percentages or comparable cross-binary coverage scores. +No sanitizer finding or crash occurred. Original logs include an ignored +`-max_number_of_features` warning: that unsupported flag has been removed from +the reproduction script. The other stated caps were active. Absence of failure +does not prove memory safety. Additional random duration was not used merely to +fill the suggested maximum budget; deterministic semantic gaps took priority. + +## Performance observations + +GCC 13.3 Release, same WSL instance and filesystem, 3,000 synthetic records per +workload, three trials each, alternating baseline/candidate order. CLI parsing, +detection and report writing are included; `/usr/bin/time` supplies peak RSS. +Median wall times rounded to hundredths of a second: + +| Workload | Baseline seconds | Candidate seconds | +|---|---:|---:| +| Dense failures | 0.04 | 0.04 | +| Unsupported | 0.06 | 0.06 | +| 4 KiB command payload | 0.35 | 0.36 | +| Distinct subjects | 0.04 | 0.04 | +| Inclusive window boundary | 0.04 | 0.04 | +| Mixed evidence | 0.06 | 0.06 | + +Observed peak RSS was below 31 MiB in both builds. All 36 invocations exited zero. +The original distinct-subject generator used addresses outside documentation +ranges; its measurements are retained privately as superseded evidence. Only +that workload was regenerated using `2001:db8::/32` and rerun after the final +sudo correction: 3,000 records and 3,000 distinct subjects, with the original +65,024-subject repetition period preserved. Three trials per build produced +median wall times 0.043066 seconds (baseline) and 0.041555 seconds (candidate), +rounded in the table above. All six runs parsed 3,000 events with no warnings or +findings. Changing the address family means these measurements must not be used +to claim a speedup against the superseded IPv4 workload. Other workload evidence +was not regenerated during the narrow final audit. Use `--workload distinct-subjects` +with the measurement script to reproduce only this corrected comparison. +These short DrvFS measurements show no obvious regression at this scale; they are +not a native Linux throughput benchmark, statistical equivalence result, or an +asymptotic resource guarantee. Raw individual timings and exact commands are +retained in the private manifest. + +## Reproduction (Linux, from repository root) + +```sh +python3 scripts/run-adversarial-qualification.py --evidence ../qualification-run --seconds 60 +python3 tests/test_adversarial_reports.py ../qualification-run/build/report_adversarial_driver ../qualification-reports --extended +cmake -S . -B ../qualification-gcc -G Ninja -DCMAKE_BUILD_TYPE=Release -DLOGLENS_WARNINGS_AS_ERRORS=ON +cmake --build ../qualification-gcc -j2 +ctest --test-dir ../qualification-gcc --output-on-failure +python3 scripts/measure-adversarial-performance.py --baseline ../baseline-build/loglens --candidate ../qualification-gcc/loglens --output ../qualification-performance +git diff --check +``` + +Use a separate archive of the pinned baseline for `baseline-build`, with identical +Release flags. Do not build over original checkouts. The campaign runner records +exact commands, exits, durations and sanitizer settings in `execution.json`; +the corpus generator emits provenance in `manifest.json`. Private baseline RED, +intermediate RED/GREEN and final validation records are kept separately, so a +successful retry cannot erase a failure. Extended campaigns remain local; CI adds +only deterministic corpus generation and a 2,000-run pipeline smoke. Remote CI +has NOT_RUN. No commit, push, publication or research modification is authorized. diff --git a/fuzz/pipeline_fuzz.cpp b/fuzz/pipeline_fuzz.cpp new file mode 100644 index 0000000..b48ffac --- /dev/null +++ b/fuzz/pipeline_fuzz.cpp @@ -0,0 +1,9 @@ +#include "../tests/support/qualification_pipeline.hpp" + +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + qualification::check_pipeline(data, size); + return 0; +} diff --git a/scripts/generate-adversarial-corpus.py b/scripts/generate-adversarial-corpus.py new file mode 100644 index 0000000..9a3388d --- /dev/null +++ b/scripts/generate-adversarial-corpus.py @@ -0,0 +1,59 @@ +"""Deterministic synthetic seeds. Never consumes real logs or contacts a service.""" +import argparse +import json +import pathlib + + +def generate(root): + entries = [] + def save(target, name, content, purpose): + path = root / target / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + entries.append({'path': path.relative_to(root).as_posix(), 'bytes': len(content), + 'provenance': 'authored synthetic fixture', 'purpose': purpose}) + messages = [ + ('sshd', 'Failed password for user-a from 203.0.113.1 port 50101 ssh2'), + ('sshd', 'Failed password for from from 203.0.113.1 port 50101 ssh2'), + ('sshd', 'Failed password for illegal user user-a from 2001:db8::1 port 50101 ssh2'), + ('sshd', 'Accepted keyboard-interactive/pam for user-a from 203.0.113.1 port 50101 ssh2'), + ('sshd', 'Failed publickey for user-a from 203.0.113.1 port 50101 ssh2'), + ('sshd', 'maximum authentication attempts exceeded for user-a from 203.0.113.1 port 50101 ssh2'), + ('sshd', 'Failed password for user-a from 203.0.113.1. port 50101 ssh2'), + ('sshd', 'Connection closed by 203.0.113.1 port 50101 [preauth]'), + ('sudo', 'user-a : TTY=pts/0 ; PWD=/ ; USER=root ; COMMAND=/usr/bin/echo from example'), + ('sudo', 'user-a : TTY=pts/0 ; PWD=/ ; USER=root ; COMMAND=/usr/bin/echo incorrect password attempt'), + ('su', 'FAILED SU (to root) user-a on pts/0'), + ('pam_unix(sshd:auth)', 'authentication failure; rhost=203.0.113.1 user=user-a'), + ] + for mode, prefix in [('syslog', 'Mar 10 09:00:00 '), ('journal', 'Tue 2026-03-10 17:00:00 +0800 ')]: + for index, (program, message) in enumerate(messages): + line = f'{prefix}example-host {program}[4200]: {message}'.encode() + save('parser', f'{mode}-{index:02}', line, 'supported/unsupported/auth-field boundaries') + seed = b'Mar 10 09:00:00 example-host sshd: Failed password for user-a from 203.0.113.1' + for host in ('example-host', 'example.example'): + for tag in ('sshd', 'sshd[0]', 'sshd[2147483647]', 'sshd[2147483648]'): + save('parser', 'envelope-' + host + '-' + tag, + f'Mar 10 09:00:00 {host} {tag}: Failed password for user-a from 203.0.113.1 port 50101 ssh2'.encode(), + 'hostname and PID presence/int boundary') + for name, content in [('truncated', seed[:45]), ('nul', seed + b'\x00'), ('invalid-utf8', seed.replace(b'example-host', b'example-\xff'))]: + save('parser', name, content, 'byte/truncation boundaries') + for day in ('0', '1', '31', '32', '-255', '257', '2147483648'): + save('timestamp_day', 'day-' + day.replace('-', 'negative-'), day.encode(), 'integer/day domain') + for name, family, users in [('ssh-threshold', 0, [0] * 5), ('sudo-threshold', 11, [0] * 3), ('multi-user', 0, [0, 1, 2])]: + seed = bytearray([0]) + for user in users: + seed.extend([family, 0, user, 0]) + save('pipeline', name, bytes(seed), 'authored threshold witness; 4-byte records') + seed = bytearray([1]) + for index in range(10): + seed.extend([0, 0 if index < 5 else 11, 0, 0]) + save('pipeline', 'separated-episodes', bytes(seed), 'two five-event bursts separated by 1802 seconds') + (root / 'manifest.json').write_text(json.dumps({'generator_version': 1, 'entries': entries}, indent=2) + '\n') + print(f'{len(entries)} synthetic seeds written') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--output', type=pathlib.Path, required=True) + generate(parser.parse_args().output) diff --git a/scripts/measure-adversarial-performance.py b/scripts/measure-adversarial-performance.py new file mode 100644 index 0000000..857c6a7 --- /dev/null +++ b/scripts/measure-adversarial-performance.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Matched local CLI observations; synthetic inputs, no historical timing claims.""" +import argparse +import json +from pathlib import Path +import subprocess +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--baseline', type=Path, required=True) + parser.add_argument('--candidate', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--lines', type=int, default=3000) + workloads = ('dense', 'unsupported', 'long-fields', 'distinct-subjects', 'window-boundary', 'mixed') + parser.add_argument('--workload', choices=workloads, help='rerun only the affected workload') + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=False) + results = [] + for workload in (args.workload,) if args.workload else workloads: + rows = [] + for i in range(args.lines): + seconds = (i % 2) * 600 if workload == 'window-boundary' else i % 600 + user = 'user-' + str(i % 7) + # Keep the original 65,024-subject period, inside the documentation prefix. + address = f'2001:db8::{i % (254 * 256) + 1:x}' if workload == 'distinct-subjects' else '203.0.113.1' + program = 'sshd' + message = f'Failed password for {user} from {address} port 50101 ssh2' + if workload == 'unsupported' or (workload == 'mixed' and i % 3 == 0): + message = 'unsupported synthetic message' + if workload == 'long-fields': + program = 'sudo' + message = 'user-a : TTY=pts/0 ; PWD=/ ; USER=root ; COMMAND=/usr/bin/echo ' + 'x' * 4096 + if workload == 'mixed' and i % 3 == 1: + message = message.replace('Failed', 'Accepted') + rows.append(f'Mar 10 09:{seconds // 60:02}:{seconds % 60:02} example-host {program}[4200]: {message}\n') + source = args.output / (workload + '.log') + source.write_text(''.join(rows), encoding='utf-8') + for repeat in range(3): + # Alternate execution order to reduce a systematic warm-cache advantage. + builds = [('baseline', args.baseline), ('candidate', args.candidate)] + if repeat % 2: + builds.reverse() + for label, binary in builds: + target = args.output / f'{workload}-{repeat}-{label}' + metrics = args.output / f'{workload}-{repeat}-{label}.time' + command = ['/usr/bin/time', '-f', '%e %M', '-o', str(metrics), str(binary.resolve()), + '--mode', 'syslog', '--year', '2026', '--csv', str(source.resolve()), str(target.resolve())] + started = time.monotonic() + run = subprocess.run(command, capture_output=True, timeout=60) + (args.output / f'{workload}-{repeat}-{label}.stderr').write_bytes(run.stderr) + observation = dict(workload=workload, repeat=repeat, build=label, command=command, + exit=run.returncode, wall_seconds=time.monotonic() - started, + lines=args.lines, input_bytes=source.stat().st_size) + if metrics.exists(): + observation['time_elapsed_rss_kib'] = metrics.read_text().strip() + results.append(observation) + (args.output / 'results.json').write_text(json.dumps(results, indent=2) + '\n') + if run.returncode: + raise SystemExit(run.returncode) + print(workload, 'complete', flush=True) + + +if __name__ == '__main__': + main() diff --git a/scripts/run-adversarial-qualification.py b/scripts/run-adversarial-qualification.py new file mode 100644 index 0000000..2739ef2 --- /dev/null +++ b/scripts/run-adversarial-qualification.py @@ -0,0 +1,69 @@ +"""Local sanitizer campaign runner with retained commands, exits and raw logs.""" +import argparse +import json +import os +import pathlib +import shutil +import subprocess +import sys +import time + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--evidence', type=pathlib.Path, required=True) + parser.add_argument('--seconds', type=int, default=60) + parser.add_argument('--seed', type=int, default=129130) + args = parser.parse_args() + if not 1 <= args.seconds <= 900: + parser.error('seconds must be 1..900 per target') + source = pathlib.Path(__file__).resolve().parents[1] + evidence = args.evidence.resolve() + evidence.mkdir(parents=True, exist_ok=True) + manifest_path = evidence / 'execution.json' + if manifest_path.exists(): + parser.error('use a new evidence directory; existing runs are immutable') + build = evidence / 'build' + records = [] + env = dict(os.environ, ASAN_OPTIONS='detect_leaks=1', UBSAN_OPTIONS='halt_on_error=1:print_stacktrace=1') + def run(label, command, timeout): + start = time.monotonic() + with (evidence / (label + '.log')).open('wb') as log: + try: + result = subprocess.run([str(x) for x in command], stdout=log, stderr=subprocess.STDOUT, + env=env, timeout=timeout, cwd=source) + code = result.returncode + except subprocess.TimeoutExpired: + code = 'TIMEOUT' + records.append({'label': label, 'command': [str(x) for x in command], 'exit': code, + 'seconds': time.monotonic() - start, + 'ASAN_OPTIONS': env['ASAN_OPTIONS'], 'UBSAN_OPTIONS': env['UBSAN_OPTIONS']}) + manifest_path.write_text(json.dumps(records, indent=2) + '\n') + print(label, code, flush=True) + if code != 0: + raise SystemExit(1) + run('configure', ['cmake', '-S', source, '-B', build, '-G', 'Ninja', + '-DCMAKE_CXX_COMPILER=clang++', '-DCMAKE_BUILD_TYPE=RelWithDebInfo', + '-DLOGLENS_WARNINGS_AS_ERRORS=ON', '-DLOGLENS_BUILD_FUZZERS=ON', + '-DCMAKE_CXX_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer', + '-DCMAKE_EXE_LINKER_FLAGS=-fsanitize=address,undefined'], 120) + run('build', ['cmake', '--build', build, '-j2'], 600) + run('ctest', ['ctest', '--test-dir', build, '--output-on-failure'], 180) + corpus = evidence / 'corpus' + run('generate', [sys.executable, source / 'scripts/generate-adversarial-corpus.py', '--output', corpus], 30) + for target, folder, max_len in [('fuzz_parser', 'parser', 4096), ('fuzz_timestamp_day', 'timestamp_day', 32), ('fuzz_pipeline', 'pipeline', 97)]: + existing = source / 'tests/fuzz/corpus' / folder + if existing.is_dir(): + for seed in existing.iterdir(): + if seed.is_file(): + shutil.copyfile(seed, corpus / folder / ('existing-' + seed.name)) + artifacts = evidence / 'failures' / folder + artifacts.mkdir(parents=True, exist_ok=True) + run(target, [build / target, corpus / folder, f'-seed={args.seed}', + f'-max_total_time={args.seconds}', f'-max_len={max_len}', '-timeout=3', + '-rss_limit_mb=1024', '-print_final_stats=1', + '-artifact_prefix=' + str(artifacts) + os.sep], args.seconds + 60) + + +if __name__ == '__main__': + main() diff --git a/src/parser.cpp b/src/parser.cpp index 1663534..979e5ae 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -85,10 +85,19 @@ std::optional AuthLogParser::parse_line(std::string_view line, auto result = parser_internal::parse_source_envelope(config_, line, line_number); if (result.matched && result.event.has_value()) { - if (auto source_ip_failure = parser_internal::classify_source_ip_failure(*result.event)) { - result = std::move(*source_ip_failure); - } else { - result = parser_internal::dispatch_program(*result.event); + const auto source = std::move(*result.event); + result = parser_internal::dispatch_program(source); + // Validate the field actually emitted by the selected handler. Opaque + // usernames and command arguments must not be scanned as IP metadata. + if (result.event.has_value()) { + if (!result.event->source_ip.empty()) { + if (auto failure = parser_internal::classify_source_ip_failure(*result.event)) { + result = std::move(*failure); + } + } + } else if (auto failure = parser_internal::classify_source_ip_failure(source)) { + // Preserve malformed-source telemetry for unsupported messages. + result = std::move(*failure); } } diff --git a/src/parser/failure_classifier.cpp b/src/parser/failure_classifier.cpp index 1149c0a..8c5c927 100644 --- a/src/parser/failure_classifier.cpp +++ b/src/parser/failure_classifier.cpp @@ -8,6 +8,9 @@ namespace loglens::parser_internal { namespace { bool is_valid_ipv4_token(std::string_view token) { + if (token.empty() || token.back() == '.') { + return false; + } int parts = 0; while (!token.empty()) { const auto dot = token.find('.'); @@ -15,6 +18,11 @@ bool is_valid_ipv4_token(std::string_view token) { if (part.empty()) { return false; } + for (const char ch : part) { + if (ch < '0' || ch > '9') { + return false; + } + } int value = 0; if (!parse_int(part, value) || value < 0 || value > 255) { @@ -33,23 +41,55 @@ bool is_valid_ipv4_token(std::string_view token) { } bool is_valid_ipv6_like_token(std::string_view token) { - if (token.find(':') == std::string_view::npos) { + // Preserve the existing exclusion of the bare "::" token. + if (token == "::") { return false; } - - bool saw_hex = false; - for (const char character : token) { - if (std::isxdigit(static_cast(character)) != 0) { - saw_hex = true; - continue; - } - if (character == ':' || character == '.') { - continue; - } + const auto compression = token.find("::"); + const bool compressed = compression != std::string_view::npos; + if (token.find(':') == std::string_view::npos + || (compressed && token.find("::", compression + 2) != std::string_view::npos)) { return false; } - - return saw_hex; + unsigned groups = 0; + const auto count_side = [&groups](std::string_view side, bool allow_ipv4) { + while (!side.empty()) { + const auto colon = side.find(':'); + const auto part = side.substr(0, colon); + if (part.empty()) { + return false; + } + if (part.find('.') != std::string_view::npos) { + if (!allow_ipv4 || colon != std::string_view::npos || !is_valid_ipv4_token(part)) { + return false; + } + groups += 2; + } else { + if (part.size() > 4) { + return false; + } + for (const unsigned char ch : part) { + if (std::isxdigit(ch) == 0) { + return false; + } + } + ++groups; + } + if (colon == std::string_view::npos) { + return true; + } + side.remove_prefix(colon + 1); + if (side.empty()) { + return false; + } + } + return true; + }; + if (!compressed) { + return count_side(token, true) && groups == 8; + } + return count_side(token.substr(0, compression), false) + && count_side(token.substr(compression + 2), true) && groups < 8; } bool is_valid_source_ip_token(std::string_view token) { @@ -215,7 +255,7 @@ ParserFailureCategory failure_category_for_unrecognized_event(const Event& event } // namespace std::optional classify_source_ip_failure(const Event& event) { - const auto candidate = extract_source_ip_candidate(event); + const auto candidate = event.source_ip.empty() ? extract_source_ip_candidate(event) : event.source_ip; if (candidate.empty() || is_valid_source_ip_token(candidate)) { return std::nullopt; } diff --git a/src/parser/pam_handlers.cpp b/src/parser/pam_handlers.cpp index 88bbe4c..e8da530 100644 --- a/src/parser/pam_handlers.cpp +++ b/src/parser/pam_handlers.cpp @@ -23,7 +23,7 @@ bool parse_pam_named_user_failure_message(std::string_view message, } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = EventType::PamAuthFailure; return true; } diff --git a/src/parser/sshd_handlers.cpp b/src/parser/sshd_handlers.cpp index 8a85cdf..2750fbb 100644 --- a/src/parser/sshd_handlers.cpp +++ b/src/parser/sshd_handlers.cpp @@ -52,7 +52,7 @@ bool parse_ssh_failed_message(std::string_view message, Event& event) { } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = invalid_user ? EventType::SshInvalidUser : EventType::SshFailedPassword; return true; } @@ -70,7 +70,7 @@ bool parse_ssh_accepted_message(std::string_view message, Event& event) { } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = EventType::SshAcceptedPassword; return true; } @@ -88,7 +88,7 @@ bool parse_ssh_accepted_publickey_message(std::string_view message, Event& event } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = EventType::SshAcceptedPublicKey; return true; } @@ -106,7 +106,7 @@ bool parse_ssh_accepted_keyboard_interactive_message(std::string_view message, E } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = EventType::SshAcceptedKeyboardInteractive; return true; } @@ -125,7 +125,7 @@ bool parse_ssh_failed_publickey_message(std::string_view message, Event& event) } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = EventType::SshFailedPublicKey; return true; } @@ -144,7 +144,7 @@ bool parse_ssh_failed_keyboard_interactive_message(std::string_view message, Eve } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = invalid_user ? EventType::SshInvalidUser : EventType::SshFailedKeyboardInteractive; return true; } @@ -168,7 +168,7 @@ bool parse_ssh_max_auth_tries_message(std::string_view message, Event& event) { } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = invalid_user ? EventType::SshInvalidUser : EventType::SshMaxAuthTries; return true; } @@ -192,7 +192,7 @@ bool parse_ssh_pam_auth_failure_message(std::string_view message, Event& event) } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = invalid_user ? EventType::SshInvalidUser : EventType::PamAuthFailure; return true; } @@ -234,7 +234,7 @@ bool parse_ssh_invalid_user_message(std::string_view message, Event& event) { } event.username.assign(username); - event.source_ip = extract_token_after(message, " from "); + event.source_ip = extract_source_after_username(remaining); event.event_type = EventType::SshInvalidUser; return true; } diff --git a/src/parser/sudo_handlers.cpp b/src/parser/sudo_handlers.cpp index bad42b6..e045b90 100644 --- a/src/parser/sudo_handlers.cpp +++ b/src/parser/sudo_handlers.cpp @@ -3,12 +3,30 @@ #include "parser/failure_classifier.hpp" #include "parser/text_utils.hpp" +#include #include #include namespace loglens::parser_internal { namespace { +std::size_t find_command_field(std::string_view details) { + // Sudo audit fields are separated by " ; "; substrings inside values are opaque. + std::size_t offset = 0; + while (offset < details.size()) { + const auto field = trim_left(details.substr(offset)); + if (field.starts_with("COMMAND=")) { + return details.size() - field.size(); + } + const auto separator = details.find(" ; ", offset); + if (separator == std::string_view::npos) { + break; + } + offset = separator + 3; + } + return std::string_view::npos; +} + bool parse_sudo_message(std::string_view message, Event& event) { auto remaining = trim_left(message); const auto separator = remaining.find(':'); @@ -23,18 +41,25 @@ bool parse_sudo_message(std::string_view message, Event& event) { event.username.assign(username); const auto details = trim_left(remaining.substr(separator + 1)); - if (details.find("incorrect password attempt") != std::string_view::npos) { + const auto command_position = find_command_field(details); + const auto metadata = details.substr(0, command_position); + const auto attempt_marker = metadata.find(" incorrect password attempt"); + const auto attempt_count = metadata.substr(0, attempt_marker); + if (attempt_marker != std::string_view::npos && !attempt_count.empty() + && std::all_of(attempt_count.begin(), attempt_count.end(), [](char ch) { + return ch >= '0' && ch <= '9'; + })) { event.event_type = EventType::SudoAuthFailure; return true; } - if (details.find("user NOT in sudoers") != std::string_view::npos - || details.find("command not allowed") != std::string_view::npos) { + if (metadata.starts_with("user NOT in sudoers") + || metadata.starts_with("command not allowed")) { event.event_type = EventType::SudoPolicyDenied; return true; } - if (details.find("COMMAND=") == std::string_view::npos) { + if (command_position == std::string_view::npos) { return false; } diff --git a/src/parser/text_utils.cpp b/src/parser/text_utils.cpp index b068e24..e786545 100644 --- a/src/parser/text_utils.cpp +++ b/src/parser/text_utils.cpp @@ -55,6 +55,15 @@ std::string extract_token_after(std::string_view input, std::string_view marker) return std::string(consume_token(remaining)); } +std::string extract_source_after_username(std::string_view remaining) { + remaining = trim_left(remaining); + if (remaining.starts_with("from ")) { + remaining.remove_prefix(5); + return std::string(consume_token(remaining)); + } + return extract_token_after(remaining, " from "); +} + std::string extract_kv_value(std::string_view input, std::string_view key) { std::size_t search_position = 0; while (search_position < input.size()) { diff --git a/src/parser/text_utils.hpp b/src/parser/text_utils.hpp index 733c1f5..e33ba09 100644 --- a/src/parser/text_utils.hpp +++ b/src/parser/text_utils.hpp @@ -5,6 +5,8 @@ namespace loglens::parser_internal { +std::string extract_source_after_username(std::string_view remaining); + std::string_view trim_left(std::string_view value); std::string_view trim(std::string_view value); std::string_view consume_token(std::string_view& input); diff --git a/src/report.cpp b/src/report.cpp index d486ad2..c92f910 100644 --- a/src/report.cpp +++ b/src/report.cpp @@ -36,11 +36,45 @@ void append_unicode_escape(std::string& output, unsigned char value) { output += escaped_code.str(); } +std::size_t valid_utf8_length(std::string_view value, std::size_t offset) { + const auto first = static_cast(value[offset]); + const std::size_t length = first >= 0xc2 && first <= 0xdf ? 2 + : first >= 0xe0 && first <= 0xef ? 3 + : first >= 0xf0 && first <= 0xf4 ? 4 : 0; + if (length == 0 || length > value.size() - offset) { + return 0; + } + for (std::size_t index = 1; index < length; ++index) { + const auto byte = static_cast(value[offset + index]); + if (byte < 0x80 || byte > 0xbf) { + return 0; + } + } + const auto second = static_cast(value[offset + 1]); + if ((first == 0xe0 && second < 0xa0) || (first == 0xed && second >= 0xa0) + || (first == 0xf0 && second < 0x90) || (first == 0xf4 && second >= 0x90)) { + return 0; + } + return length; +} + std::string escape_json(std::string_view value) { std::string escaped; escaped.reserve(value.size()); - for (const char character : value) { + for (std::size_t index = 0; index < value.size(); ++index) { + const char character = value[index]; + if (static_cast(character) >= 0x80) { + const auto length = valid_utf8_length(value, index); + if (length != 0) { + escaped.append(value.substr(index, length)); + index += length - 1; + } else { + // Invalid input bytes must not invalidate the entire JSON document. + append_unicode_escape(escaped, static_cast(character)); + } + continue; + } switch (character) { case '\\': escaped += "\\\\"; @@ -175,8 +209,9 @@ std::string escape_csv(std::string_view value) { return escaped; } -void write_text_file(const std::filesystem::path& path, std::string_view content) { - std::ofstream output(path); +void write_text_file(const std::filesystem::path& path, std::string_view content, + std::ios::openmode mode = std::ios::out) { + std::ofstream output(path, mode); if (!output) { throw std::runtime_error("unable to open report output: " + path.string()); } @@ -810,8 +845,9 @@ void write_reports(const ReportData& data, const std::filesystem::path& output_d if (!emit_csv) { return; } - write_text_file(findings_csv_path, render_findings_csv(data)); - write_text_file(warnings_csv_path, render_warnings_csv(data)); + // CSV field newlines are data; Windows text translation must not alter them. + write_text_file(findings_csv_path, render_findings_csv(data), std::ios::out | std::ios::binary); + write_text_file(warnings_csv_path, render_warnings_csv(data), std::ios::out | std::ios::binary); } } // namespace loglens diff --git a/tests/report_adversarial_driver.cpp b/tests/report_adversarial_driver.cpp new file mode 100644 index 0000000..866794a --- /dev/null +++ b/tests/report_adversarial_driver.cpp @@ -0,0 +1,77 @@ +#include "report.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 2 && !(argc == 3 && std::string(argv[2]) == "--extended")) { + throw std::runtime_error("expected output directory"); + } + const std::filesystem::path root{argv[1]}; + std::filesystem::create_directories(root); + std::vector fields{ + "example-user", "=1+1", " +1", "-1", "@example", "\t=1", "\r\n=1", + "quote\",comma,pipe|slash\\&", std::string("NUL\0end", 7), + std::string("control\x01\x1f", 9), "valid-\xc3\xa9-\xe4\xb8\xad-\xf0\x9f\x98\x80", + "bad-\xff", "bad-\xc0\xaf", "bad-\xed\xa0\x80", "bad-\xf4\x90\x80\x80", + "truncated-\xe2\x82", "mixed-\xe2\x82\xac-\xff-\xc3\xa9"}; + if (argc == 3) { + // Portable fixed-state byte mutation; paths never derive from these fields. + std::uint32_t state = 129130; + for (unsigned sample = 0; sample < 256; ++sample) { + std::string value(1, static_cast(sample)); + for (unsigned byte = 0; byte < sample % 64; ++byte) { + state = state * 1664525U + 1013904223U; + value.push_back(static_cast(state >> 24)); + } + fields.push_back(std::move(value)); + } + } + std::ofstream manifest(root / "fields.txt", std::ios::binary); + for (std::size_t index = 0; index < fields.size(); ++index) { + const auto& field = fields[index]; + manifest << std::dec << index << ' '; + for (const unsigned char ch : field) { + manifest << std::hex << std::setfill('0') << std::setw(2) << static_cast(ch); + } + manifest << '\n'; + loglens::ReportData data; + // Empty path prevents the renderer from rereading unrelated input files. + data.parse_metadata.assume_year = 2026; + data.parser_quality.total_lines = 3; + data.parser_quality.parsed_lines = 2; + data.parser_quality.unparsed_lines = 1; + loglens::Event event; + event.event_type = loglens::EventType::SshAcceptedPassword; + event.hostname = field; + event.username = field; + event.line_number = 1; + data.events.push_back(event); + event.hostname = "example-fixed-host"; + event.line_number = 2; + data.events.push_back(event); + loglens::Finding finding; + finding.type = loglens::FindingType::SudoBurst; + finding.subject_kind = "username"; + finding.subject = field; + finding.usernames = {field}; + finding.summary = field; + finding.event_count = 1; + finding.evidence_event_ids = {"line:1"}; + data.findings = {finding}; + data.warnings.push_back({3, field, loglens::ParserFailureCategory::KnownProgramUnknownMessage}); + for (const auto& copy : {"first", "second"}) { + loglens::write_reports(data, root / std::to_string(index) / copy, true); + } + } + if (!manifest) { + throw std::runtime_error("cannot write synthetic field manifest"); + } + std::cout << fields.size() << " synthetic report cases\n"; +} diff --git a/tests/support/qualification_cases.hpp b/tests/support/qualification_cases.hpp new file mode 100644 index 0000000..86ae1b2 --- /dev/null +++ b/tests/support/qualification_cases.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "event.hpp" + +#include +#include + +namespace qualification { +struct Case { + std::string program; + std::string message; + loglens::EventType type; + std::string username; + std::string address; + bool attempt; + bool terminal; + bool sudo; +}; + +// Independently authored expectations from the conformance matrix and rule catalog. +inline std::vector cases() { + using T = loglens::EventType; + return { + {"sshd", "Failed password for user-a from 203.0.113.1 port 50101 ssh2", T::SshFailedPassword, "user-a", "203.0.113.1", true, true, false}, + {"sshd", "Failed password for illegal user user-a from 203.0.113.1 port 50101 ssh2", T::SshInvalidUser, "user-a", "203.0.113.1", true, true, false}, + {"sshd", "Failed none for invalid user user-a from 203.0.113.1 port 50101 ssh2", T::SshInvalidUser, "user-a", "203.0.113.1", true, true, false}, + {"sshd", "Accepted password for user-a from 203.0.113.1 port 50101 ssh2", T::SshAcceptedPassword, "user-a", "203.0.113.1", false, false, false}, + {"sshd", "Accepted publickey for user-a from 203.0.113.1 port 50101 ssh2", T::SshAcceptedPublicKey, "user-a", "203.0.113.1", false, false, false}, + {"sshd", "Accepted keyboard-interactive/pam for user-a from 203.0.113.1 port 50101 ssh2", T::SshAcceptedKeyboardInteractive, "user-a", "203.0.113.1", false, false, false}, + {"sshd", "Failed publickey for invalid user user-a from 203.0.113.1 port 50101 ssh2", T::SshFailedPublicKey, "user-a", "203.0.113.1", true, true, false}, + {"sshd", "Failed keyboard-interactive/pam for user-a from 203.0.113.1 port 50101 ssh2", T::SshFailedKeyboardInteractive, "user-a", "203.0.113.1", true, true, false}, + {"sshd", "maximum authentication attempts exceeded for user-a from 203.0.113.1 port 50101 ssh2", T::SshMaxAuthTries, "user-a", "203.0.113.1", true, true, false}, + {"sshd", "PAM: Authentication failure for user-a from 203.0.113.1", T::PamAuthFailure, "user-a", "203.0.113.1", true, false, false}, + {"sshd", "input_userauth_request: invalid user user-a [preauth]", T::SshInvalidUser, "user-a", "", true, true, false}, + {"sudo", "user-a : TTY=pts/0 ; PWD=/ ; USER=root ; COMMAND=/usr/bin/true", T::SudoCommand, "user-a", "", false, false, true}, + {"sudo", "user-a : 1 incorrect password attempt ; TTY=pts/0 ; COMMAND=/usr/bin/true", T::SudoAuthFailure, "user-a", "", false, false, false}, + {"sudo", "user-a : user NOT in sudoers ; COMMAND=/usr/bin/true", T::SudoPolicyDenied, "user-a", "", false, false, false}, + {"su", "FAILED SU (to root) user-a on pts/0", T::SuAuthFailure, "user-a", "", false, false, false}, + {"su", "Successful su for root by user-a", T::SessionOpened, "user-a", "", false, false, false}, + {"login", "LOGIN ON pts/0 BY user-a FROM example-host", T::SessionOpened, "user-a", "", false, false, false}, + {"pam_unix(sshd:auth)", "authentication failure; rhost=203.0.113.1 user=user-a", T::PamAuthFailure, "user-a", "203.0.113.1", true, false, false}, + {"pam_unix(sudo:session)", "session opened for user root by user-a(uid=1000)", T::SessionOpened, "user-a", "", false, false, false}, + {"pam_faillock(sshd:auth)", "Authentication failure for user user-a from 203.0.113.1", T::PamAuthFailure, "user-a", "203.0.113.1", true, false, false}, + {"pam_sss(sshd:auth)", "received for user user-a: 7 (Authentication failure)", T::PamAuthFailure, "user-a", "", true, false, false}, + }; +} +} // namespace qualification diff --git a/tests/support/qualification_pipeline.hpp b/tests/support/qualification_pipeline.hpp new file mode 100644 index 0000000..f89389c --- /dev/null +++ b/tests/support/qualification_pipeline.hpp @@ -0,0 +1,194 @@ +#pragma once + +#include "qualification_cases.hpp" +#include "detector.hpp" +#include "parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qualification { +inline void demand(bool value, const char* message) { + if (!value) { + throw std::runtime_error(message); + } +} + +struct Record { + Case sample; + int seconds; + std::size_t line; +}; + +struct ExpectedFinding { + loglens::FindingType type; + std::string subject; + std::size_t observed; + std::vector ids; + int first; + int last; + std::vector usernames; +}; + +inline void replace_all(std::string& value, const std::string& old, const std::string& replacement) { + std::size_t offset = 0; + while ((offset = value.find(old, offset)) != std::string::npos) { + value.replace(offset, old.size(), replacement); + offset += replacement.size(); + } +} + +inline std::vector records(const std::uint8_t* data, std::size_t size) { + static const auto families = cases(); + static constexpr int times[]{0, 1, 299, 300, 301, 599, 600, 601, 899, 900, 901, 1802}; + std::vector result; + for (std::size_t offset = 1; offset + 3 < size && result.size() < 24; offset += 4) { + auto sample = families[data[offset] % families.size()]; + const auto username = data[offset + 2] % 5 == 4 ? std::string("from") + : "user-" + std::to_string(data[offset + 2] % 4); + const auto address = data[offset + 3] % 2 == 0 ? "203.0.113.1" : "203.0.113.2"; + replace_all(sample.message, "user-a", username); + replace_all(sample.message, "203.0.113.1", address); + sample.username = username; + if (!sample.address.empty()) { + sample.address = address; + } + result.push_back({std::move(sample), times[data[offset + 1] % 12], result.size() + 1}); + } + return result; +} + +inline std::string render_input(const std::vector& input, bool journal) { + std::ostringstream output; + for (const auto& record : input) { + const int minute = record.seconds / 60; + const int second = record.seconds % 60; + output << (journal ? "Tue 2026-03-10 09:" : "Mar 10 09:"); + if (minute < 10) { output << '0'; } + output << minute << ':'; + if (second < 10) { output << '0'; } + output << second << (journal ? " UTC " : " ") << "example-host " + << record.sample.program << "[4200]: " << record.sample.message << '\n'; + } + return output.str(); +} + +inline std::vector expected_findings(const std::vector& input) { + std::vector expected; + for (int rule = 0; rule < 3; ++rule) { + const int window = rule == 0 ? 600 : rule == 1 ? 900 : 300; + const std::size_t threshold = rule == 0 ? 5 : 3; + const auto type = rule == 0 ? loglens::FindingType::BruteForce + : rule == 1 ? loglens::FindingType::MultiUserProbing : loglens::FindingType::SudoBurst; + std::map> groups; + for (const auto& record : input) { + const auto& sample = record.sample; + const bool eligible = rule == 0 ? sample.terminal : rule == 1 ? sample.attempt : sample.sudo; + const auto& key = rule == 2 ? sample.username : sample.address; + if (eligible && !key.empty()) { groups[key].push_back(&record); } + } + for (auto& [subject, group] : groups) { + std::sort(group.begin(), group.end(), [](const auto* a, const auto* b) { + return std::tie(a->seconds, a->line) < std::tie(b->seconds, b->line); + }); + std::vector> components; + for (const auto* record : group) { + if (components.empty() || record->seconds - components.back().back()->seconds > window) { + components.emplace_back(); + } + components.back().push_back(record); + } + for (const auto& component : components) { + // Enumerate all legal intervals, independently of the production sliding window. + std::vector best; + std::set best_users; + std::size_t best_score = 0; + for (std::size_t end = 0; end < component.size(); ++end) { + for (std::size_t begin = 0; begin <= end; ++begin) { + if (component[end]->seconds - component[begin]->seconds > window) { continue; } + std::set users; + for (std::size_t j = begin; j <= end; ++j) { + if (!component[j]->sample.username.empty()) { users.insert(component[j]->sample.username); } + } + const std::size_t count = end - begin + 1; + const auto score = rule == 1 ? users.size() : count; + if (score > best_score || (score == best_score && count > best.size())) { + best.assign(component.begin() + static_cast(begin), + component.begin() + static_cast(end + 1)); + best_score = score; + best_users = std::move(users); + } + } + } + if (best_score >= threshold) { + ExpectedFinding finding{type, subject, best_score, {}, best.front()->seconds, best.back()->seconds, {}}; + for (const auto* record : best) { finding.ids.push_back("line:" + std::to_string(record->line)); } + if (rule == 1) { finding.usernames.assign(best_users.begin(), best_users.end()); } + expected.push_back(std::move(finding)); + } + } + } + } + return expected; +} + +inline void check_findings(const std::vector& actual, + const std::vector& expected) { + demand(actual.size() == expected.size(), "detector finding count"); + std::set identities; + std::set consumed; + std::map, std::size_t> episodes; + for (const auto& finding : actual) { + const auto match = std::find_if(expected.begin(), expected.end(), [&](const auto& item) { + return item.type == finding.type && item.subject == finding.subject && item.ids == finding.evidence_event_ids; + }); + demand(match != expected.end(), "detector evidence references"); + demand(consumed.insert(static_cast(match - expected.begin())).second, + "detector duplicate expected finding"); + const bool sudo_rule = finding.type == loglens::FindingType::SudoBurst; + const auto rule = sudo_rule ? "sudo_burst" + : finding.type == loglens::FindingType::BruteForce ? "brute_force" : "multi_user_probing"; + demand(finding.rule_id == rule && finding.threshold == (finding.type == loglens::FindingType::BruteForce ? 5U : 3U) + && finding.subject_kind == (sudo_rule ? "username" : "source_ip") + && finding.grouping_key == finding.subject_kind, "detector rule metadata"); + demand(finding.observed_count == match->observed && finding.event_count == match->ids.size(), "detector threshold/count"); + demand(finding.first_seen.time_since_epoch().count() == 1773133200 + match->first + && finding.last_seen.time_since_epoch().count() == 1773133200 + match->last, + "detector selected time window"); + demand(finding.usernames == match->usernames, "detector username set"); + demand(!finding.finding_id.empty() && identities.insert(finding.finding_id).second, "detector duplicate identity"); + demand(finding.episode_index == ++episodes[{finding.type, finding.subject}], "detector episode numbering"); + } +} + +inline void check_parsed(const loglens::ParseReport& parsed, const std::vector& input) { + demand(parsed.warnings.empty() && parsed.events.size() == input.size(), "generated supported evidence lost"); + for (std::size_t i = 0; i < input.size(); ++i) { + const auto& event = parsed.events[i]; + const auto& record = input[i]; + demand(event.event_type == record.sample.type && event.username == record.sample.username + && event.source_ip == record.sample.address && event.line_number == record.line + && event.hostname == "example-host" && event.program == record.sample.program + && event.pid == 4200 && event.message == record.sample.message + && event.timestamp.time_since_epoch().count() == 1773133200 + record.seconds, + "generated normalized fields"); + } +} + +inline void check_pipeline(const std::uint8_t* data, std::size_t size) { + const auto input = records(data, size); + const bool journal = size != 0 && (data[0] & 1) != 0; + const loglens::AuthLogParser parser({journal ? loglens::InputMode::JournalctlShortFull + : loglens::InputMode::SyslogLegacy, 2026}); + std::istringstream stream(render_input(input, journal)); + const auto parsed = parser.parse_stream(stream); + check_parsed(parsed, input); + check_findings(loglens::Detector{}.analyze(parsed.events), expected_findings(input)); +} +} // namespace qualification diff --git a/tests/test_adversarial_reports.py b/tests/test_adversarial_reports.py new file mode 100644 index 0000000..1ad910e --- /dev/null +++ b/tests/test_adversarial_reports.py @@ -0,0 +1,88 @@ +"""Independent UTF-8/JSON/CSV consumers and observation-corruption controls.""" +import csv +import io +import json +import pathlib +import subprocess +import sys + + +def json_field(raw): + # Preserve valid Unicode; represent each malformed UTF-8 byte as U+00XX. + return ''.join(chr(ord(ch) - 0xDC00) if 0xDC80 <= ord(ch) <= 0xDCFF else ch + for ch in raw.decode('utf-8', errors='surrogateescape')) + + +def csv_field(raw): + value = raw.decode('latin1') # Byte-preserving CSV oracle, independent of UTF-8 policy. + stripped = value.lstrip(' ') + return "'" + value if stripped and stripped[0] in '=+-@\t\r\n' else value + + +def check_json(raw, expected): + data = json.loads(raw.decode('utf-8')) + assert data['findings'][0]['subject'] == expected + assert data['findings'][0]['summary'] == expected + assert data['findings'][0]['usernames'] == [expected] + assert data['warnings'][0]['reason'] == expected + assert {host['hostname'] for host in data['host_summaries']} == {expected, 'example-fixed-host'} + assert sum(item['count'] for item in data['event_counts']) == data['parsed_event_count'] == 2 + for host in data['host_summaries']: + assert sum(item['count'] for item in host['event_counts']) == host['parsed_event_count'] == 1 + + +def check_csv(raw, expected): + rows = list(csv.reader(io.StringIO(raw.decode('latin1'), newline=''), strict=True)) + assert len(rows) == 2 and len(rows[0]) == len(rows[1]) == 8 + assert rows[1][2] == rows[1][6] == rows[1][7] == expected + + +def detected(check, raw, expected): + try: + check(raw, expected) + except (AssertionError, UnicodeError, ValueError, csv.Error, KeyError, IndexError): + return True + return False + + +def main(): + driver, output = sys.argv[1:3] + extra = sys.argv[3:] + if extra not in ([], ['--extended']): + raise ValueError('only --extended is supported') + root = pathlib.Path(output) + subprocess.run([driver, str(root), *extra], check=True, timeout=120) + failures = [] + controls = 0 + for entry in (root / 'fields.txt').read_text().splitlines(): + index, encoded = entry.split(' ') + raw = bytes.fromhex(encoded) + folder = root / index / 'first' + expected_json, expected_csv = json_field(raw), csv_field(raw) + content = (folder / 'report.json').read_bytes() + csv_content = (folder / 'findings.csv').read_bytes() + try: + check_json(content, expected_json) + check_csv(csv_content, expected_csv) + warnings = list(csv.reader(io.StringIO((folder / 'warnings.csv').read_bytes().decode('latin1'), newline=''), strict=True)) + assert len(warnings) == 2 and len(warnings[1]) == 4 and warnings[1][3] == expected_csv + for name in ('report.json', 'report.md', 'findings.csv', 'warnings.csv'): + assert (folder / name).read_bytes() == (root / index / 'second' / name).read_bytes() + except (AssertionError, UnicodeError, ValueError, csv.Error) as error: + failures.append({'case': index, 'error': str(error) or type(error).__name__}) + continue + # Controls corrupt the actual serialized artifacts consumed by the checker. + assert detected(check_json, content[:-3], expected_json), 'JSON truncation control survived' + changed = json.loads(content) + changed['findings'][0]['subject'] = 'corrupted-observation' + assert detected(check_json, json.dumps(changed).encode(), expected_json), 'JSON field control survived' + assert detected(check_csv, csv_content + b'rogue,row\n', expected_csv), 'CSV row control survived' + controls += 3 + result = {'failures': failures, 'negative_controls_detected': controls} + (root / 'results.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result)) + return 1 if failures else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_adversarial_semantics.cpp b/tests/test_adversarial_semantics.cpp new file mode 100644 index 0000000..061a151 --- /dev/null +++ b/tests/test_adversarial_semantics.cpp @@ -0,0 +1,391 @@ +#include "parser.hpp" +#include "detector.hpp" +#include "signal.hpp" +#include "support/qualification_cases.hpp" +#include "support/qualification_pipeline.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +void require(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +std::string envelope(bool journal, const std::string& program, const std::string& message) { + return (journal ? "Tue 2026-03-10 09:00:00 UTC " : "Mar 10 09:00:00 ") + + std::string("example-host ") + program + "[4200]: " + message; +} + +loglens::AuthLogParser parser(bool journal) { + return loglens::AuthLogParser({journal ? loglens::InputMode::JournalctlShortFull + : loglens::InputMode::SyslogLegacy, 2026}); +} + +void malformed_addresses() { + // Authored lexical counterexamples; no production validator supplies expected results. + const std::vector invalid{ + "203.0.113.1.", "203.0.113.-0", "2001:db8:1", "2001:::db8", + "2001:db8::1::2", "2001:db8:00000::1", "2001:db8:1:2:3:4:5:6:7", + "::", "2001:db8::203.0.113.999", ":2001:db8:0:0:0:0:0:1", "2001:db8:0:0:0:0:0:1:", + "2001:db8::1:2:3:4:5:6", "invalid user user-b 203.0.113.1"}; + for (const bool journal : {false, true}) { + for (const auto& address : invalid) { + const auto line = envelope(journal, "sshd", "Failed password for user-a from " + + address + " port 50101 ssh2"); + std::string reason; + auto category = loglens::ParserFailureCategory::UnknownProgram; + const auto event = parser(journal).parse_line(line, 1, &reason, &category); + require(!event, "malformed source admitted: " + address); + require(category == loglens::ParserFailureCategory::MalformedSourceIp, + "malformed source taxonomy changed"); + std::istringstream stream(line + '\n' + line + '\n' + line + '\n' + line + '\n' + line); + const auto parsed = parser(journal).parse_stream(stream); + require(parsed.events.empty() && parsed.warnings.size() == 5, + "malformed stream became events"); + require(loglens::Detector{}.analyze(parsed.events).empty(), + "malformed source produced findings"); + } + } +} + +void valid_addresses() { + const std::vector valid{ + "203.0.113.1", "2001:db8::1", "2001:db8:0:0:0:0:0:1", + "2001:db8::203.0.113.1", "2001:db8::", "::ffff:203.0.113.1", + "2001:db8:0:0:0:0:203.0.113.1", "2001:db8::1:2:3:4:5"}; + for (const bool journal : {false, true}) { + for (const auto& address : valid) { + const auto event = parser(journal).parse_line(envelope(journal, "sshd", + "Failed password for user-a from " + address + " port 50101 ssh2"), 7); + require(event && event->source_ip == address + && event->event_type == loglens::EventType::SshFailedPassword, + "supported address changed: " + address); + } + } +} + +void envelope_variants() { + for (const bool journal : {false, true}) { + for (const auto& host : {"example-host", "example.example", "host-2"}) { + for (const auto& tag : {"sshd", "sshd[0]", "sshd[2147483647]"}) { + const auto line = std::string(journal ? "Tue 2026-03-10 09:00:00 UTC " : "Mar 10 09:00:00 ") + + host + " " + tag + ": Failed password for user-a from 203.0.113.1 port 50101 ssh2"; + const auto event = parser(journal).parse_line(line, 3); + require(event && event->hostname == host && event->program == "sshd" + && event->username == "user-a" && event->source_ip == "203.0.113.1" + && event->timestamp.time_since_epoch().count() == 1773133200, + "envelope variation changed authentication evidence"); + const std::string tag_value{tag}; + require(tag_value == "sshd" ? !event->pid.has_value() + : event->pid == (tag_value == "sshd[0]" ? 0 : 2147483647), + "PID boundary normalized incorrectly"); + } + } + } +} + +void sudo_payload() { + const std::vector payloads{ + "from example", "rhost=example", "incorrect password attempt", + "command not allowed", "user NOT in sudoers"}; + for (const bool journal : {false, true}) { + for (const auto& payload : payloads) { + const auto line = envelope(journal, "sudo", + "user-a : TTY=pts/0 ; PWD=/ ; USER=root ; COMMAND=/usr/bin/echo " + payload); + std::istringstream stream(line + '\n' + line + '\n' + line); + const auto result = parser(journal).parse_stream(stream); + require(result.events.size() == 3 && result.warnings.empty(), + "sudo command payload caused evidence loss: " + payload); + for (const auto& event : result.events) { + require(event.event_type == loglens::EventType::SudoCommand + && event.username == "user-a" && event.source_ip.empty(), + "sudo command payload changed classification: " + payload); + } + const auto findings = loglens::Detector{}.analyze(result.events); + require(findings.size() == 1 && findings[0].type == loglens::FindingType::SudoBurst + && findings[0].event_count == 3 + && findings[0].evidence_event_ids == std::vector{"line:1", "line:2", "line:3"}, + "sudo command payload changed detector evidence"); + } + } +} + +void sudo_denial_precedence() { + for (const bool journal : {false, true}) { + for (const auto& prefix : {"user NOT in sudoers", "command not allowed"}) { + const auto event = parser(journal).parse_line(envelope(journal, "sudo", + std::string("user-a : ") + prefix + " ; COMMAND=/usr/bin/echo example"), 1); + require(event && event->event_type == loglens::EventType::SudoPolicyDenied, + "actual denial metadata lost precedence"); + require(loglens::Detector{}.analyze({*event, *event, *event}).empty(), + "denied command became sudo burst"); + } + } +} + +void marker_username() { + for (const bool journal : {false, true}) { + for (const auto& prefix : {"Failed password for ", "Accepted password for ", + "Accepted publickey for ", "Failed publickey for ", + "Failed keyboard-interactive/pam for ", "Invalid user "}) { + const auto event = parser(journal).parse_line(envelope(journal, "sshd", + std::string(prefix) + "from from 203.0.113.1 port 50101 ssh2"), 1); + require(event && event->username == "from" && event->source_ip == "203.0.113.1", + "username marker consumed as source delimiter"); + } + } +} + +void sudo_metadata_values() { + for (const auto& value : {"incorrect password attempt", "command not allowed", "from example"}) { + const auto event = parser(false).parse_line(envelope(false, "sudo", + std::string("user-a : TTY=pts/0 ; PWD=/tmp/") + value + + " ; USER=root ; COMMAND=/usr/bin/true"), 1); + require(event && event->event_type == loglens::EventType::SudoCommand, + "opaque PWD value changed sudo event"); + } +} + +void sudo_command_field_boundaries() { + for (const bool journal : {false, true}) { + for (const auto& details : { + "TTY=pts/0 ; PWD=/tmp/COMMAND=marker ; USER=root", + "TTY=pts/0 ; PWD=/tmp/;COMMAND=marker ; USER=root", + "TTY=pts/0 ; PWD=/ ; USER=root ; NOTCOMMAND=/usr/bin/true"}) { + const auto line = envelope(journal, "sudo", std::string("user-a : ") + details); + std::string reason; + auto category = loglens::ParserFailureCategory::UnknownProgram; + require(!parser(journal).parse_line(line, 1, &reason, &category), + "sudo value substring admitted as command field"); + require(category == loglens::ParserFailureCategory::KnownProgramUnknownMessage + && reason == "unrecognized auth pattern: sudo_other", "sudo missing-field taxonomy"); + std::istringstream stream(line + '\n' + line + '\n' + line); + const auto parsed = parser(journal).parse_stream(stream); + require(parsed.events.empty() && parsed.warnings.size() == 3, + "sudo missing command became normalized evidence"); + require(loglens::Detector{}.analyze(parsed.events).empty(), + "sudo missing command produced a burst"); + } + for (const auto& details : { + "COMMAND=/usr/bin/true", + "TTY=pts/0 ; PWD=/tmp/COMMAND=marker ; USER=root ; COMMAND=/usr/bin/true", + "TTY=pts/0 ; PWD=/ ; USER=root ; COMMAND=/usr/bin/echo COMMAND=payload"}) { + const auto line = envelope(journal, "sudo", std::string("user-a : ") + details); + std::istringstream stream(line + '\n' + line + '\n' + line); + const auto parsed = parser(journal).parse_stream(stream); + require(parsed.events.size() == 3 && parsed.warnings.empty(), "supported sudo command field lost"); + for (const auto& event : parsed.events) { + require(event.event_type == loglens::EventType::SudoCommand, "sudo field classification changed"); + } + const auto findings = loglens::Detector{}.analyze(parsed.events); + require(findings.size() == 1 && findings[0].type == loglens::FindingType::SudoBurst + && findings[0].evidence_event_ids == std::vector{"line:1", "line:2", "line:3"}, + "supported sudo command lost burst evidence"); + } + } +} + +void check_expected_event(const loglens::Event& event, const qualification::Case& expected) { + require(event.event_type == expected.type && event.username == expected.username + && event.source_ip == expected.address && event.program == expected.program, + "authored normalization expectation failed"); + require(event.timestamp.time_since_epoch().count() == 1773133200, + "independent epoch anchor failed"); +} + +void family_matrix() { + for (const auto& sample : qualification::cases()) { + for (const bool journal : {false, true}) { + const auto event = parser(journal).parse_line(envelope(journal, sample.program, sample.message), 1); + require(event.has_value(), "supported family lost: " + sample.program); + check_expected_event(*event, sample); + } + } +} + +void line_stream_equivalence() { + for (const bool journal : {false, true}) { + std::string text; + const auto samples = qualification::cases(); + for (const auto& sample : samples) { + text += envelope(journal, sample.program, sample.message) + '\n'; + } + std::istringstream input(text); + const auto result = parser(journal).parse_stream(input); + require(result.events.size() == samples.size() && result.warnings.empty(), "stream lost family"); + for (std::size_t i = 0; i < samples.size(); ++i) { + check_expected_event(result.events[i], samples[i]); + require(result.events[i].line_number == i + 1, "stream reference changed"); + } + } +} + +void signal_eligibility() { + for (const auto& sample : qualification::cases()) { + const auto event = parser(false).parse_line(envelope(false, sample.program, sample.message), 7); + require(event.has_value(), "signal fixture did not parse"); + const auto signals = loglens::build_auth_signals({*event}, {}); + const auto count = [&](auto predicate) { return std::count_if(signals.begin(), signals.end(), predicate); }; + require(count([](const auto& s) { return s.counts_as_attempt_evidence; }) == (sample.attempt ? 1 : 0) + && count([](const auto& s) { return s.counts_as_terminal_auth_failure; }) == (sample.terminal ? 1 : 0) + && count([](const auto& s) { return s.counts_as_sudo_burst_evidence; }) == (sample.sudo ? 1 : 0), + "default eligibility differs from independent table"); + for (const auto& signal : signals) { + require(signal.event_id == "line:7" && signal.line_number == 7, "signal provenance changed"); + } + } +} + +void unsupported_and_blank_insertion() { + const auto line = envelope(false, "sshd", "Failed password for user-a from 203.0.113.1 port 50101 ssh2"); + const auto unsupported = envelope(false, "sshd", "Connection closed by 203.0.113.1 port 50101 [preauth]"); + std::istringstream input(line + "\n\n" + unsupported + "\n" + line + "\n" + line + "\n" + line + "\n" + line); + const auto parsed = parser(false).parse_stream(input); + require(parsed.events.size() == 5 && parsed.warnings.size() == 1 + && parsed.quality.skipped_blank_lines == 1 && parsed.quality.total_lines == 6, + "insertions violated accounting"); + require(parsed.warnings[0].line_number == 3 + && parsed.warnings[0].category == loglens::ParserFailureCategory::KnownProgramUnknownMessage + && parsed.quality.top_unknown_patterns.size() == 1 + && parsed.quality.top_unknown_patterns[0].pattern == "sshd_connection_closed_preauth" + && parsed.quality.top_unknown_patterns[0].count == 1, "unsupported telemetry lost"); + const auto findings = loglens::Detector{}.analyze(parsed.events); + require(findings.size() == 1 && findings[0].event_count == 5 + && findings[0].evidence_event_ids == std::vector{"line:1", "line:4", "line:5", "line:6", "line:7"}, + "unsupported/blank line entered detector evidence"); +} + +void timezone_equivalence() { + for (const auto& prefix : {"Tue 2026-03-10 09:00:00 UTC", "Tue 2026-03-10 09:00:00 Z", + "Tue 2026-03-10 09:00:00 GMT", "Tue 2026-03-10 17:00:00 +0800", + "Tue 2026-03-11 08:59:00 +23:59", "Mon 2026-03-09 09:01:00 -2359"}) { + const auto event = parser(true).parse_line(std::string(prefix) + + " example-host sshd: Failed password for user-a from 203.0.113.1 port 50101 ssh2", 1); + require(event && event->timestamp.time_since_epoch().count() == 1773133200, + "timezone instant/rollover changed"); + } +} + +void invalid_timestamps() { + for (const auto& prefix : {"Tue 2026-02-29 09:00:00 UTC", "Tue 2026-03-10 24:00:00 UTC", + "Tue 2026-03-10 09:00:60 UTC", "Tue 2026-03-10 09:00:00 +2400", + "Tue 2026-03-10 09:00:00 +1260", "Tue 2026-04-31 09:00:00 UTC"}) { + std::string error; + auto category = loglens::ParserFailureCategory::UnknownProgram; + const auto event = parser(true).parse_line(std::string(prefix) + + " example-host sshd: Failed password for user-a from 203.0.113.1 port 50101 ssh2", 1, &error, &category); + require(!event && category == loglens::ParserFailureCategory::UnknownTimestamp && !error.empty(), + "illegal timestamp admitted"); + } +} + +void exhaustive_pipeline_properties() { + std::uint32_t state = 0x129130U; + for (unsigned run = 0; run < 512; ++run) { + std::vector bytes(1 + 4 * (run % 24)); + for (auto& byte : bytes) { + state = state * 1664525U + 1013904223U; + byte = static_cast(state >> 24U); + } + qualification::check_pipeline(bytes.data(), bytes.size()); + } + // Explicit threshold/window witnesses supplement random structured combinations. + for (const auto family : {0U, 11U}) { + for (const auto count : {2U, 3U, 4U, 5U, 6U}) { + for (unsigned endpoint = 2; endpoint <= 10; ++endpoint) { + std::vector bytes{0}; + for (unsigned i = 0; i < count; ++i) { + bytes.insert(bytes.end(), {static_cast(family), + static_cast(i + 1 == count ? endpoint : 0), 0, 0}); + } + qualification::check_pipeline(bytes.data(), bytes.size()); + } + } + } +} + +void oracle_negative_controls() { + std::vector bytes{0}; + for (int i = 0; i < 10; ++i) { + bytes.insert(bytes.end(), {0, static_cast(i < 5 ? 0 : 11), 0, 0}); + } + const auto input = qualification::records(bytes.data(), bytes.size()); + const auto expected = qualification::expected_findings(input); + require(expected.size() == 2, "negative-control witness needs two episodes"); + std::istringstream stream(qualification::render_input(input, false)); + const auto parsed = parser(false).parse_stream(stream); + const auto findings = loglens::Detector{}.analyze(parsed.events); + qualification::check_parsed(parsed, input); + qualification::check_findings(findings, expected); + const auto reject = [&](const char* label, const char* diagnostic, auto check) { + bool detected = false; + try { check(); } + catch (const std::runtime_error& error) { detected = error.what() == std::string(diagnostic); } + require(detected, std::string("targeted corruption not detected: ") + label); + std::cout << "RED_DETECTED " << label << '\n'; + }; + auto lost = parsed; + lost.events.pop_back(); + lost.warnings.push_back({10, "deliberately unsupported", loglens::ParserFailureCategory::KnownProgramUnknownMessage}); + reject("supported-to-warning", "generated supported evidence lost", [&] { qualification::check_parsed(lost, input); }); + auto admitted = parsed; + admitted.events.push_back(parsed.events.front()); + reject("unsupported-to-evidence", "generated supported evidence lost", [&] { qualification::check_parsed(admitted, input); }); + auto altered = parsed; + altered.events[0].timestamp += std::chrono::seconds{1}; + reject("altered-timestamp", "generated normalized fields", [&] { qualification::check_parsed(altered, input); }); + altered = parsed; + altered.events[0].username = "corrupted-user"; + reject("altered-field", "generated normalized fields", [&] { qualification::check_parsed(altered, input); }); + auto corrupt = findings; + ++corrupt[0].observed_count; + reject("wrong-threshold-count", "detector threshold/count", [&] { qualification::check_findings(corrupt, expected); }); + corrupt = findings; + corrupt[0].evidence_event_ids.pop_back(); + reject("missing-evidence", "detector evidence references", [&] { qualification::check_findings(corrupt, expected); }); + corrupt = findings; + corrupt[0].evidence_event_ids.push_back(corrupt[0].evidence_event_ids[0]); + reject("duplicate-evidence", "detector evidence references", [&] { qualification::check_findings(corrupt, expected); }); + corrupt = findings; + ++corrupt[0].episode_index; + reject("wrong-episode", "detector episode numbering", [&] { qualification::check_findings(corrupt, expected); }); +} +} // namespace + +int main() { + int failures = 0; + const auto run = [&](const char* name, auto test) { + try { + test(); + std::cout << "PASS " << name << '\n'; + } catch (const std::exception& error) { + ++failures; + std::cerr << "FAIL " << name << ": " << error.what() << '\n'; + } + }; + run("malformed-source-rejection", malformed_addresses); + run("valid-source-preservation", valid_addresses); + run("envelope-host-pid-variation", envelope_variants); + run("sudo-command-payload-isolation", sudo_payload); + run("sudo-denial-metadata-precedence", sudo_denial_precedence); + run("marker-word-username", marker_username); + run("sudo-metadata-value-isolation", sudo_metadata_values); + run("sudo-command-field-boundaries", sudo_command_field_boundaries); + run("authored-family-envelope-matrix", family_matrix); + run("line-stream-equivalence", line_stream_equivalence); + run("default-signal-eligibility", signal_eligibility); + run("unsupported-blank-telemetry-provenance", unsupported_and_blank_insertion); + run("timezone-instant-equivalence", timezone_equivalence); + run("invalid-timestamp-taxonomy", invalid_timestamps); + run("exhaustive-window-pipeline-model", exhaustive_pipeline_properties); + run("targeted-oracle-negative-controls", oracle_negative_controls); + return failures == 0 ? 0 : 1; +}