Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,27 @@ jobs:
- name: Build + test under ThreadSanitizer
run: cmake --preset tsan && cmake --build --preset tsan && ctest --preset tsan --output-on-failure

# Security fuzzing smoke — libFuzzer harnesses for the untrusted-input components must build
# and survive a short, bounded run under ASan/UBSan (roadmap 11.5, ADR-0031). Deeper campaigns
# run out-of-band; this gate catches crashers on every PR while staying minutes-cheap.
fuzz:
name: fuzz / libfuzzer smoke
runs-on: ubuntu-24.04
timeout-minutes: 20
env: { CC: clang, CXX: clang++ }
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: lukka/get-cmake@591817e96fcad43505fb4eae36172462abb3a42e # v4.3.3
- name: Build the fuzz harnesses (Clang + libFuzzer)
run: cmake --preset fuzz && cmake --build --preset fuzz
- name: Run each harness for a bounded time
shell: bash
run: |
for t in fuzz_json_parser fuzz_cli_parser fuzz_binary_serializer; do
echo "== $t =="
./build/fuzz/"$t" -max_total_time=20 -rss_limit_mb=2048 -print_final_stats=1
done

# Line-coverage gate — the library must stay at >= 80% line coverage (roadmap 10.3, ADR-0028).
coverage:
name: coverage / line >= 80%
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,17 @@ PR. A release PR moves the `[Unreleased]` entries into a new per-version file un
eight benchmarked hot paths: machine-independent algorithmic-class invariants plus relative
regression thresholds (≤1.25× baseline median, p99 ≤2× median) with a stated methodology
(roadmap 11.6).
- Security: coverage-guided libFuzzer harnesses for the untrusted-input components (`JsonParser`,
`CliParser`, `BinaryDeserializer`) under `src/fuzz/`, gated by `EGL_UTIL_BUILD_FUZZERS` with a
`fuzz` preset and a CI smoke job; a threat model (`docs/security/threat-model.md`); and extended
NIST FIPS 180-4 SHA-256 test vectors (ADR-0031, roadmap 11.5).

### Changed

- Documentation: `hash.hpp` now states the hashes' **non-cryptographic** scope explicitly —
SHA-256 is an integrity/checksum digest, not a security primitive (not constant-time; not for
passwords/MACs/signatures). Corrects the prior "the cryptographic digest" wording (ADR-0031).

### Deprecated

### Removed
Expand Down
9 changes: 9 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ endif()

option(EGL_UTIL_BUILD_TESTS "Build the egl-util-cpp test suite" ${EGL_UTIL_IS_TOP_LEVEL})
option(EGL_UTIL_BUILD_BENCH "Build the egl-util-cpp benchmark suite" ${EGL_UTIL_IS_TOP_LEVEL})
# Fuzz harnesses for the untrusted-input components (ADR-0031). Built in standalone replay mode
# by default (so they compile on the whole matrix and stay in the clang-tidy compile database);
# set EGL_UTIL_FUZZER_ENGINE=libfuzzer with a Clang toolchain for coverage-guided fuzzing.
option(EGL_UTIL_BUILD_FUZZERS "Build the egl-util-cpp fuzz harnesses" ${EGL_UTIL_IS_TOP_LEVEL})
set(EGL_UTIL_FUZZER_ENGINE "" CACHE STRING "Fuzzing engine: '' (standalone replay) or 'libfuzzer'")
option(EGL_UTIL_BUILD_DOCS "Add the 'docs' Doxygen target when Doxygen is available" ${EGL_UTIL_IS_TOP_LEVEL})
# Header-only is the default contract; the compiled STATIC tier is strictly opt-in (ADR-0004).
option(EGL_UTIL_BUILD_STATIC "Build the optional compiled STATIC library tier" OFF)
Expand Down Expand Up @@ -56,6 +61,10 @@ if(EGL_UTIL_BUILD_BENCH)
add_subdirectory(src/bench/cpp/it/d4np/util)
endif()

if(EGL_UTIL_BUILD_FUZZERS)
add_subdirectory(src/fuzz/cpp/it/d4np/util)
endif()

# --- API documentation (Doxygen) -----------------------------------------------------------
# Adds an opt-in `docs` target (built with `cmake --build --preset <p> --target docs`); it is
# not part of the default build, so a toolchain without Doxygen still builds the library.
Expand Down
13 changes: 13 additions & 0 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"CMAKE_POLICY_VERSION_MINIMUM": "3.5",
"EGL_UTIL_BUILD_TESTS": "ON",
"EGL_UTIL_BUILD_BENCH": "ON",
"EGL_UTIL_BUILD_FUZZERS": "ON",
"EGL_UTIL_BUILD_STATIC": "ON"
}
},
Expand Down Expand Up @@ -66,6 +67,17 @@
"name": "bench",
"inherits": "release"
},
{
"name": "fuzz",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"EGL_UTIL_FUZZER_ENGINE": "libfuzzer",
"EGL_UTIL_BUILD_TESTS": "OFF",
"EGL_UTIL_BUILD_BENCH": "OFF",
"EGL_UTIL_BUILD_STATIC": "OFF"
}
},
{
"name": "coverage",
"inherits": "debug",
Expand All @@ -82,6 +94,7 @@
{ "name": "ubsan", "configurePreset": "ubsan" },
{ "name": "tsan", "configurePreset": "tsan" },
{ "name": "bench", "configurePreset": "bench" },
{ "name": "fuzz", "configurePreset": "fuzz" },
{ "name": "coverage", "configurePreset": "coverage" }
],
"testPresets": [
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ document the security posture. Documentation- and assurance-focused; no public A
exception-safety level, allocation behavior, and algorithmic complexity.
- [x] 11.4 State a single distribution model with an explicit ABI-stability policy for the
compiled tier (augment ADR-0004 / spec §1); remove any header-only vs compiled ambiguity.
- [ ] 11.5 Add a security section: threat model for the untrusted-input components
- [x] 11.5 Add a security section: threat model for the untrusted-input components
(BinarySerializer, CliParser, JsonParser), fuzzing harnesses (libFuzzer targets), and a
SHA-256 non-cryptographic scoping statement with extended NIST test-vector validation.
- [x] 11.6 Turn the benchmark baselines into documented numeric targets/regression thresholds
Expand Down
8 changes: 8 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Security Policy

## Threat model & posture

The library's trust boundary, the attack surface of the untrusted-input components
(`JsonParser`, `CliParser`, `BinaryDeserializer`), their mitigations, the fuzzing strategy, and
the **non-cryptographic scope of the hash functions** (SHA-256 included) are documented in
[`docs/security/threat-model.md`](docs/security/threat-model.md) (decisions in
[ADR-0031](docs/adr/0031-security-fuzzing-and-hash-scoping.md)).

## Supported versions

Until `egl-util-cpp` reaches `v1.0.0`, only the latest released minor line receives
Expand Down
92 changes: 92 additions & 0 deletions docs/adr/0031-security-fuzzing-and-hash-scoping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# ADR-0031: Fuzz-testing harnesses & non-cryptographic hash scoping

- **Status:** Accepted
- **Date:** 2026-07-05
- **Deciders:** Daniel Polo (maintainer), agent session
- **Related:** spec §2 (components #18, #22, #23, #24), §3 (no UB), §6 (verification),
roadmap 11.5, [ADR-0022](0022-json-parser-non-allocating-pull-events.md) (JsonParser strictness
& bounded depth), [ADR-0021](0021-cli-parser-typed-binding-value-or-error.md) (CliParser
value-or-error), [ADR-0024](0024-binary-serializer-endianness-aware-header-only-codec.md)
(BinarySerializer bounds), [ADR-0029](0029-error-handling-policy.md) (error model),
[`SECURITY.md`](../../SECURITY.md), [`docs/security/threat-model.md`](../security/threat-model.md)

## Context

The post-1.0 security review flagged that the three components which consume **untrusted input**
— `JsonParser` (#23), `CliParser` (#22), and `BinarySerializer`/`BinaryDeserializer` (#18) — carry
no threat model and no fuzzing plan, and that the `constexpr` SHA-256 (#24) has no test-vector
requirement beyond the single `"abc"` digest and no statement of whether it is offered as a
*cryptographic* primitive. Two decisions follow: **how the untrusted-input boundary is
continuously exercised** (a fuzzing strategy that fits a zero-dependency, header-first library and
the existing CI), and **what security scope the hash functions claim**.

## Decision

**Add coverage-guided fuzz harnesses for the three untrusted-input components, and explicitly
scope every hash in `hash.hpp` — SHA-256 included — as a *non-cryptographic* integrity primitive.**

### Fuzzing

- **One libFuzzer harness per untrusted-input component**, under a new source tier
`src/fuzz/cpp/it/d4np/util/` (`fuzz_json_parser`, `fuzz_cli_parser`, `fuzz_binary_serializer`),
gated behind the `EGL_UTIL_BUILD_FUZZERS` CMake option. Each drives the *full* untrusted path:
the JSON harness walks `next()` to completion and `decode_string()`s every string/key (the
escape/`\uXXXX`/surrogate decoder is the sharpest edge); the CLI harness splits the buffer into
argv tokens and runs `parse`; the binary harness reads a mixed scalar/`read_bytes` sequence off
arbitrary bytes.
- **Dual-mode, so the harnesses build everywhere.** With Clang + `EGL_UTIL_FUZZER_ENGINE=libfuzzer`
they compile with `-fsanitize=fuzzer,address,undefined`. Otherwise they build in a **standalone
replay** mode (their own `main` runs each input file once) — so the whole CI matrix (incl. MSVC)
compiles them, they stay in the `clang-tidy` compile database, and a crashing corpus entry can be
replayed locally without libFuzzer. The shared body is one cast-free function per harness.
- **CI runs a short fuzz smoke job** (Clang, `fuzz` preset): build all three and run each for a
bounded `-max_total_time`, so a crasher fails the PR while the run stays minutes-cheap. Longer
campaigns are run out-of-band; found inputs land as regression corpus / `docs/bugs/` entries.
- **The invariant the fuzzers assert:** these components **never crash, never invoke UB, never
throw on input** (ADR-0029/0022/0021) — ASan/UBSan under libFuzzer turn any violation into a
failure. This is the mechanical backstop for the "malformed input is normal input" contract.

### Hash scoping

- **`fnv1a`, `murmur3`, and SHA-256 are integrity/checksum utilities, not cryptographic
primitives.** SHA-256 is provided for content-addressing, deduplication, corruption detection,
and as a standards-conformant digest for interop/test vectors — **not** for passwords, MACs/HMAC,
signatures, or any secret-dependent decision. The `constexpr`, byte-oriented implementation is
**not constant-time** and is not hardened against side channels; secret-dependent use is out of
scope. This is stated on the header and in the threat model.
- **Conformance is pinned to NIST FIPS 180-4 vectors:** the empty string, `"abc"`, the 56-byte
two-block message, and the one-million-`'a'` vector — the short ones as compile-time
`static_assert`, the 1 M one at run time (too large for a comfortable constant-evaluation budget).
A wrong digest fails the build or the test.

## Alternatives Considered

- **A fuzzing dependency (OSS-Fuzz integration, AFL++ only)** — deferred: libFuzzer ships with the
Clang already in the matrix, needs no third-party package, and integrates as a normal CMake
target. OSS-Fuzz onboarding is a good *later* step and is not precluded (the harnesses are the
standard `LLVMFuzzerTestOneInput` shape it expects).
- **Fuzz-only targets (no standalone mode)** — rejected: they would compile only under Clang, drop
out of the tidy compile database, and give no local replay path. The dual-mode `#ifdef` costs a
few lines and keeps the harnesses first-class on the whole matrix.
- **Make SHA-256 a cryptographic primitive** — rejected (the maintainer's explicit scope call): a
real cryptographic offering demands a constant-time implementation, side-channel review, and
probably HMAC — a different project. The honest, minimal position is "integrity, not crypto",
which matches a `constexpr` byte-at-a-time digest.
- **Gate a long fuzz campaign in CI** — rejected: minutes-expensive and flaky; CI runs a short
smoke, deeper runs are out-of-band.

## Consequences

- The untrusted-input boundary is now continuously, mechanically exercised; a regression that
crashes or trips ASan/UBSan on malformed input fails CI instead of shipping.
- `hash.hpp`'s header comment is corrected (it previously called SHA-256 "the cryptographic
digest"); consumers get an unambiguous scope and will not mistake it for a password/MAC hash.
- New build surface: the `EGL_UTIL_BUILD_FUZZERS` option (default ON at top level, standalone mode),
a `fuzz` preset (Clang/libFuzzer), and a CI `fuzz` job. Header-only consumers are unaffected.
- Patterns catalogue: unchanged (a testing/security decision, not a design pattern).

## References

- Spec §2 (#18/#22/#23/#24), §3 (no UB), §6 (verification); ADR-0029/0022/0021/0024.
- NIST FIPS 180-4 (SHA-256 and its test vectors); LLVM libFuzzer documentation.
- [`docs/security/threat-model.md`](../security/threat-model.md), [`SECURITY.md`](../../SECURITY.md).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ Status transitions: `Proposed` → `Accepted` → (`Superseded by ADR-XXXX` | `D
| [0028](0028-line-coverage-gate-gcovr-80-percent.md) | Line-coverage gate — gcovr in CI at ≥80%, OpenCppCoverage locally | Accepted |
| [0029](0029-error-handling-policy.md) | Library-wide error-handling policy (value-or-error vs exceptions) | Accepted |
| [0030](0030-abi-stability-policy.md) | Distribution model & ABI-stability policy | Accepted |
| [0031](0031-security-fuzzing-and-hash-scoping.md) | Fuzz-testing harnesses & non-cryptographic hash scoping | Accepted |
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 2026-07-05 — Security: threat model, fuzzing, hash scoping (roadmap 11.5)

## What got done

The one Milestone-11 item with a code component. Closes the review's security gap:

- **Threat model** (`docs/security/threat-model.md`): trust boundary, per-component attack surface
+ mitigations for the three untrusted-input components (`JsonParser`, `CliParser`,
`BinaryDeserializer`), and the hash non-crypto scope. Linked from `SECURITY.md` and spec §6.
- **libFuzzer harnesses** (`src/fuzz/cpp/it/d4np/util/`): one per untrusted-input component, gated
by `EGL_UTIL_BUILD_FUZZERS`, with a `fuzz` preset (Clang/libFuzzer) and a CI `fuzz` smoke job.
- **SHA-256 non-cryptographic scoping** (decision locked): corrected `hash.hpp`'s header (it
literally said "SHA-256 is the cryptographic digest") + a `@warning` on `sha256`; documented in
the threat model and ADR-0031.
- **Extended NIST FIPS 180-4 vectors** in `hash_test.cpp`: the 56-byte two-block message
(compile-time `static_assert`) and the one-million-`'a'` message (runtime).
- **ADR-0031** records the fuzzing strategy and the hash-scoping decision.

## Design notes worth keeping

- **Dual-mode harnesses.** Each harness is a real `LLVMFuzzerTestOneInput`, but when NOT built
with libFuzzer it compiles a standalone replay `main` (`fuzz_standalone.hpp`). So the whole CI
matrix (incl. MSVC) builds them, they stay in the `clang-tidy` compile database (the base preset
sets `EGL_UTIL_BUILD_FUZZERS=ON`), and a crashing corpus entry can be replayed locally without
libFuzzer. The `fuzz` preset turns on `-fsanitize=fuzzer,address,undefined`; non-Clang toolchains
fall back to standalone with a warning.
- **Tidy-clean without disables.** The libFuzzer ABI hands you `const uint8_t* data, size_t size`;
the enabled cppcoreguidelines set bans `reinterpret_cast` and raw pointer arithmetic. Bridged it
cast-free: wrap in `std::span{data,size}`, then range-copy into a `std::string` (JSON/CLI, element
`uint8_t→char`) or `std::as_bytes` (BinaryDeserializer). `decode_string` is called on the type
(`JsonParser<>::decode_string`), not the instance, to satisfy
`readability-static-accessed-through-instance`. No `NOLINT`.
- **What the fuzzers assert:** never crash / never UB / never throw on input — the mechanical
backstop for the "malformed input is normal input" contract (ADR-0022/0021/0024/0029).

## Verification

- `python tools/consistency_lint.py` → OK (ADR index sequential through 0031).
- Local build/format status recorded in the PR (libFuzzer itself needs Clang, absent on this
Windows/MSVC box; the standalone harnesses + the extended SHA-256 vectors build under MSVC).

## Project state

- Milestones 1–10 complete. **Milestone 11 in progress:** 11.1–11.6 done; **only 11.7 remains.**

## How the next session resumes

- One PR at a time: after 11.5 merges, do **11.7 — the last M11 item**: fix the spec §3 example so
it compiles and demonstrates constexpr `FlatMap` construction, and reconcile the spec §5
"`FlatMap::find` returns an optional-like result" wording (still on ~line 95) with the shipped
iterator-returning API. That closes Milestone 11; then it's a release PR (MINOR — new fuzz build
surface + docs) to cut the next version and close the M11 GitHub milestone.
Loading
Loading