libmodsecurity v3 reference adapter
Runs the conformance data in ../../tests against
libmodsecurity v3 through its C API,
with ctypes, as unittest.
export MODSECURITY_LIB=/path/to/libmodsecurity.so # or .dylib
uv run python -m unittest discover -s adapters/libmodsecurity # everything
uv run python -m unittest discover -s adapters/libmodsecurity -p test_conformance.py -v
uv run python -m unittest discover -s adapters/libmodsecurity -p test_adapter.py # adapter self-testsWithout MODSECURITY_LIB the conformance tests and the engine smoke tests are skipped.
Building the library
CI builds v3.0.16 from source (.github/workflows/validate.yml, job
libmodsecurity-adapter) and caches the install. The same recipe works locally; the
build needs the libxml2, yajl and PCRE2 development packages so that XML and JSON bodies
and the regex operators are available:
git clone --depth 1 -b v3.0.16 --recurse-submodules --shallow-submodules https://github.com/owasp-modsecurity/ModSecurity.git
cd ModSecurity && ./build.sh
./configure --prefix=$HOME/modsecurity --disable-doxygen-doc --disable-examples \
--without-lua --without-lmdb --without-ssdeep --without-curl --without-geoip --without-maxmind
make -j"$(nproc)" && make install
export MODSECURITY_LIB=$HOME/modsecurity/lib/libmodsecurity.soOn macOS with Homebrew add --with-pcre2=/opt/homebrew/opt/pcre2 --with-libxml=/opt/homebrew/opt/libxml2 --with-yajl=/opt/homebrew/opt/yajl and point
MODSECURITY_LIB at lib/libmodsecurity.dylib.
Differential run
differential.py replays the requests adapters/coraza/cmd/differential generated (with
what Coraza did) through libmodsecurity, writes what libmodsecurity did in the same JSON
shape for seclang-eval, and prints every request on which the two engines disagree:
MODSECURITY_LIB=… uv run python adapters/libmodsecurity/differential.py formal/.lake/differential.json --out formal/.lake/v3.json
cd formal && lake exe seclang-eval .lake/v3.jsonProfiles with a libmodsecurity row in compat/known-gaps.md are skipped. An engine
disagreement means at least one engine diverges from the specification on that request;
seclang-eval on the two files says which, and the divergence then gets a Core profile and
a known-gaps row like any other. CI runs it after the Lean job on the same generated file.
How results are gated
compat/known-gaps.md is the expected-failure list; rows whose Engine cell names
libmodsecurity v3 apply. For each test file:
| Row for this engine | Result | Outcome |
|---|---|---|
| none | all pass | pass |
| none | any failure | fail (a real defect in the engine or in the specification) |
| present | any failure | pass, failures printed as expected (known gap: …) |
| present | all pass | fail with known-gaps row is obsolete (remove the row) |
Extended features the library does not implement are skipped, not failed: profiles
through requires:, unit files through the UNSUPPORTED_* sets in test_conformance.py
(empty for v3.0.16 built as above).
How the tiers are driven
- Engine tier (
engine_tier.py): the profile’sfiles:and its rules are written under a temporary directory and loaded withmsc_rules_add_file, so relativeIncludepaths resolve.SecDebugLog/SecDebugLogLevel 4are appended because the C API has no “matched rules” call that includesnologrules; the debug-log segment written during the stage is parsed for(Rule: N),Rule returned 0|1.,Executing unconditional ruleandRunning (disruptive) action:lines.interruptioncombines that withmsc_intervention(status, URL);blockis resolved toredirectwhen the intervention carries a URL and todenyotherwise.log_containssearches the error-log callback lines plus the intervention’s own log line (v3 routes a denying rule’s message there). - Unit tier (
unit_tier.py): each case is a rule overREQUEST_BODYcarryinglog; the input is sent as raw bytes (unit strings are Latin-1 byte strings). Transformation output is read from the error log throught:hexEncode,msg:'%{MATCHED_VAR}', which is binary safe and untruncated;re_groupsthrough oneTX:irule per group. Parameters are inserted verbatim: v3 keeps\\pairs and treats\"as two characters, and no unit parameter contains".
Known limits
- An engine crash cannot be caught from
ctypesand aborts the run. - The debug-log line formats are those of v3.0.16; a release that changes them needs
engine_tier.parse_debug_logupdated (its tests intest_adapter.pypin the formats). - In-memory persistent collections are process-global in libmodsecurity, so profiles
that use
initcolshare state within one run; only one profile does today.