Sanitizers, Secure Coding, and CMake CI: Trapping C/C++ Risks Inside the Engineering Pipeline
The engineering quality of C/C++ cannot be sustained by code reviews alone as a single point of failure. The language boundaries are too low, the types of errors are too numerous, and platform differences are all too real. The reliable approach is to string together sanitizers, static analysis, secure coding rules, CMake targets, compilation matrices, artifact auditing, and rollback strategies into a CI/CD pipeline. This article discusses how to make risks visible before a merge, rather than guessing at them after a production crash.
The Goal of Engineering is a Chain of Evidence
A C/C++ change should leave behind at least the following evidence:
- Which compiler was used.
- Which language standard was used.
- Which warnings were enabled.
- Which libraries were linked.
- Whether sanitizers were run.
- Whether static analysis passed.
- Which platforms were covered by unit tests.
- Whether exported symbols changed.
- Whether the binary size is anomalous.
- How to degrade and rollback if an issue occurs.
Without a chain of evidence, diagnosing production failures relies purely on guesswork and experience.
Warning Strategies Must Be Tiered
-Wall does not mean all warnings.
Different compilers have different warning sets.
Projects should establish a baseline set of warnings and elevate critical warnings to errors.
target_compile_options(core PRIVATE
$<$<CXX_COMPILER_ID:Clang,GNU>:
-Wall -Wextra -Wconversion -Wshadow -Werror=return-type>
)
Do not blindly apply the same rules to third-party dependencies. Otherwise, external warnings will drown out your own business logic signals. Internal code and external code must be isolated.
ASan Observes Memory Accesses
AddressSanitizer (ASan) detects memory errors through compiler instrumentation and runtime shadow memory. Common findings include:
- Heap buffer overflow.
- Stack buffer overflow.
- Global buffer overflow.
- Use-after-free.
- Use-after-return.
- Double-free.
- Invalid free.
c++ -std=c++23 -g -O1 \
-fsanitize=address \
-fno-omit-frame-pointer \
test.cpp
ASan will alter memory layout and performance. It is suited for testing, CI, and canary diagnostics. It cannot replace permission isolation or input validation.
UBSan Observes Language Boundaries
UndefinedBehaviorSanitizer (UBSan) catches certain types of undefined behavior at runtime.
c++ -std=c++23 -g -O1 \
-fsanitize=undefined \
ub_test.cpp
Typical findings include:
- Signed integer overflow.
- Misaligned memory access.
- Invalid enumeration values.
- Division by zero.
- Null pointer-related errors.
- Invalid downcasts.
UBSan is not a complete proof of correctness. It can only report on executed paths. However, it shifts many "only crashes in Release mode" issues left into the testing phase.
TSan Observes Data Races
ThreadSanitizer (TSan) observes memory accesses across threads. It is highly relevant for concurrent modules, caches, task queues, and shared state.
c++ -std=c++23 -g -O1 \
-fsanitize=thread \
queue_test.cpp
TSan and ASan generally cannot be enabled simultaneously. CI pipelines need to split them into different jobs. Concurrency tests must include repetitive runs and timeout controls to prevent sporadic paths from slipping through.
Static Analysis Fills in Unexecuted Paths
Sanitizers depend on code execution.
Static analysis can inspect unexecuted paths.
Common tools include clang-tidy, compiler built-in analyzers, commercial SAST tools, and custom rules.
clang-tidy src/*.cpp -- -std=c++23 -Iinclude
Static analysis is excellent at discovering:
- Returning addresses of local variables.
- Unchecked return values.
- Suspicious copies.
- Incorrect lock usage.
- Unclear ownership transfers.
- Unsafe C APIs.
False positives must be triaged. Do not disable the entire toolset just because of a few false positives.
CERT Rules are the Language of Security Audits
The SEI CERT C/C++ Coding Standard is not a style guide. It compiles the root causes of common vulnerabilities into actionable rules. Projects can begin adopting these starting with the highest-risk domains.
| Risk Domain | Rule Focus | Engineering Action |
|---|---|---|
| Input | Boundary checks, integer conversions | Fuzzing, UBSan |
| Memory | Lifetimes, deallocation, out-of-bounds | ASan, Ownership encapsulation |
| Concurrency | Data races, deadlocks | TSan, Lock hierarchy auditing |
| API | Replacing unsafe functions | Linting, encapsulation |
| Error Handling | Return values and exception paths | Fault injection |
The value of these rules lies in forming a common language. During code review, don't just say "this is dangerous"; articulate exactly which boundary is violated.
CMake Targets are Engineering Boundaries
Modern CMake should be organized around targets. A target carries source files, include paths, compilation flags, linked libraries, and propagation rules.
add_library(core STATIC
src/buffer.cpp
src/parser.cpp
)
target_compile_features(core PUBLIC cxx_std_23)
target_include_directories(core
PUBLIC include
PRIVATE src
)
target_link_libraries(app PRIVATE core)
PUBLIC, PRIVATE, and INTERFACE dictate whether dependencies propagate.
This correlates directly with C/C++ header contracts.
Incorrect propagation pollutes the global scope with compile options and include paths.
Sanitizers Should Be Target Configurations
Do not scatter sanitizer flags across hand-written commands.
Control them via CMake options to ensure CI and local builds remain consistent.
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
if (ENABLE_ASAN)
target_compile_options(core PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(core PRIVATE -fsanitize=address)
endif()
Large projects can extract this into a function:
function(enable_asan target_name)
target_compile_options(${target_name} PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(${target_name} PRIVATE -fsanitize=address)
endfunction()
This makes sanitizer configuration auditable, reusable, and easily reversible.
The CI Matrix Must Cover Standards, Compilers, and Configurations
Building on only one platform does not prove portability. A matrix should cover at least:
- GCC release.
- Clang release.
- Debug + ASan/UBSan.
- TSan job.
- Release + strict warnings.
- Minimum supported standard library versions.
- Cross-compilation to target platforms.
linux-gcc-release
linux-clang-asan-ubsan
linux-clang-tsan
macos-clang-release
windows-msvc-release
A matrix isn't better just because it's larger. It should be designed around the project's actual deployment footprint and risk profile.
Fuzzing Can Shatter Parser Assumptions
C/C++ modules that process inputs are prime targets for fuzzing. Protocol parsing, file formats, compression, codecs, and SQL extensions are all high-value targets.
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
parse_packet(data, size);
return 0;
}
Fuzzing must be combined with sanitizers. This way, out-of-bounds accesses and UB triggered by random inputs are reported immediately. Corpora and crash cases must be archived to prevent regressions.
Artifact Auditing Covers Linkage and ABI
Passing the build is not enough. Release artifacts must be inspected for:
- Exported symbols.
- Dynamic library dependencies.
- RPATH.
- Binary size.
- Debug symbols.
- Licenses and third-party libraries.
- ABI diffs.
nm -D --defined-only libcore.so | sort > symbols.txt
readelf -d libcore.so > dynamic.txt
These results should be compared against a baseline. Unidentified new symbols might indicate an expanded ABI exposure surface.
Safe Degradation Must Be Designed Upfront
C/C++ modules often sit in high-performance or low-level critical paths. When an error occurs, the system needs a degradation path.
Examples:
- Native parsing fails -> fallback to a safe, slow path.
- SIMD path crashes -> fallback to scalar implementation.
- Plugin load fails -> disable the plugin rather than terminating the main process.
- Memory pool throws -> fallback to the system allocator.
- Task times out -> cancel the task and release resources.
Degradation is not about sweeping bugs under the rug. Degradation protects the system boundaries while preserving audit evidence.
Logs Must Serve Binary Troubleshooting
Troubleshooting C/C++ crashes requires build information. Logs should contain at least:
- Version number and git commit.
- Compiler and standard library versions.
- Architecture and ABI.
- Sanitizer configuration (if any).
- Dynamic library versions.
- Request or task IDs.
- Resource handle states.
Without this information, it is incredibly difficult to map a crash stack back to a specific build artifact.
Engineering Checklist
- Enable a baseline set of warnings for internal code.
- Split ASan/UBSan/TSan into separate CI jobs.
- Require
clang-tidyor static analysis checks before merges. - Hook high-risk input modules into fuzzers.
- Organize dependencies using CMake targets as boundaries.
- Manage sanitizer configurations via CMake
options. - Archive symbols and dynamic dependencies for release artifacts.
- Require audit notes for ABI changes.
- Ensure critical native paths have degradation strategies.
- Include build and ABI information in crash logs.
Summary
Security in C/C++ is not about running a single check command after writing the code. It is an unbroken chain of evidence stretching from source code, compilation flags, target dependencies, runtime observability, static analysis, fuzzing, symbol auditing, all the way to release and rollback strategies. Only by establishing this pipeline can the low-level capabilities of the language be prevented from becoming low-level risks.