kanopi/crs-engine

Standalone PHP engine that parses the OWASP Core Rule Set (CRS) and evaluates HTTP requests against it.

Maintainers

Package info

github.com/kanopi/crs-engine

pkg:composer/kanopi/crs-engine

Transparency log

Statistics

Installs: 767

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0


README

A standalone, pure-PHP engine that parses the OWASP Core Rule Set and evaluates HTTP requests against it. No FFI, no sidecars, no external runtimes — just PHP.

It exists so that a PHP-based firewall (or any PHP application) can speak the same rule format as ModSecurity / Coraza / CRS without shelling out, embedding a Go binary, or hand-translating thousands of regexes.

This package is a sibling of kanopi/firewall and is consumed by it through a thin plugin adapter — but the engine itself depends on nothing in the firewall and can be used standalone in any framework (Symfony, Laravel, Drupal, WordPress, raw PHP).

Table of contents

How it works

The engine has three stages:

  1. Refresh (build time)bin/refresh-crs downloads a pinned CRS release from GitHub, parses every REQUEST-*.conf and RESPONSE-*.conf file with the bundled SecLang parser, and writes the result to rules/:

    • rules/<source>.json — one human-reviewable JSON file per CRS source file, used to make diff review of CRS bumps painless.
    • rules/compiled.php — a single var_export'd PHP array that opcache can preload, used as the runtime hot path.
    • rules/manifest.json — version, rule counts, parser warnings.
  2. Load (process start)CrsEngine's constructor reads rules/compiled.php once. With opcache enabled, subsequent processes hit a warm cache and pay almost no cost.

  3. Evaluate (per request) — the application adapts its framework request into a RequestData DTO and calls $engine->evaluate($request). The evaluator resolves CRS target expressions against the request, applies transforms, runs operators (mostly @rx), accumulates per-category anomaly scores, and returns a CrsVerdict carrying the action (allow / log / block), matched rules, and scores.

The refresh step runs on a schedule in CI — never on production hot paths.

Requirements

  • PHP 8.1 or higher (no upper bound; tested in CI against 8.1, 8.2, 8.3)
  • ext-json, ext-mbstring, ext-pcre (all bundled with standard PHP builds)
  • Composer

The package has no runtime composer dependencies — only phpunit, phpstan, rector, and php_codesniffer for development.

Installation

composer require kanopi/crs-engine

After installing, generate the rule cache once:

vendor/bin/refresh-crs

This downloads the CRS release pinned in the package's .crs-version and populates vendor/kanopi/crs-engine/rules/. You only need to do this on fresh installs or when the pinned tag changes.

Quick start

use Kanopi\Crs\CrsConfig;
use Kanopi\Crs\CrsEngine;
use Kanopi\Crs\Request\RequestData;

// Construct once per process — loading the ruleset is the expensive part.
$engine = new CrsEngine(new CrsConfig(
    paranoia: 1,
    mode: CrsConfig::MODE_BLOCK,
));

$verdict = $engine->evaluate(RequestData::fromGlobals());

if ($verdict->isBlocked()) {
    http_response_code(403);
    error_log(sprintf(
        'CRS blocked request: %s, score %d, %d rule(s) matched',
        $verdict->blockingRuleId === null
            ? 'anomaly threshold reached'
            : 'rule ' . $verdict->blockingRuleId,
        $verdict->totalScore,
        count($verdict->matchedRules),
    ));
    exit;
}

fromGlobals() is for plain PHP and quick experiments. Behind a framework, build the DTO from its request object instead:

$request = new RequestData(
    method:      $r->getMethod(),
    uri:         $r->getRequestUri(),
    rawUri:      $r->server->get('REQUEST_URI', '/'),
    queryString: $r->server->get('QUERY_STRING', ''),
    protocol:    $r->server->get('SERVER_PROTOCOL', 'HTTP/1.1'),
    remoteAddr:  $r->getClientIp() ?? '0.0.0.0',
    queryArgs:   $r->query->all(),
    postArgs:    $r->request->all(),
    cookies:     $r->cookies->all(),
    headers:     array_map(static fn (array $v): string => $v[0], $r->headers->all()),
    body:        (string) $r->getContent(),
);

Include Content-Length on requests that have a body. CRS rule 920180 treats a POST carrying neither Content-Length nor Transfer-Encoding as request smuggling and will score it. fromGlobals() handles this; a hand-built DTO has to supply it.

There is intentionally no built-in framework adapter — keeping the engine framework-free is the point. Adapters for Symfony, PSR-7, Laravel and Drupal are a few lines each, as above.

Configuration

All configuration is constructor arguments on CrsConfig. Build it directly or with CrsConfig::fromArray() if you load config from YAML / env.

new CrsConfig(
    paranoia: 1,                        // 1 (default) - 4. Higher = more strict, more false positives.
    mode: CrsConfig::MODE_BLOCK,        // or MODE_MONITOR (records matches, never blocks)
    anomalyThresholds: [
        'inbound'  => 5,                // request score >= this blocks the request
        'outbound' => 4,                // response score >= this blocks the response
    ],
    disabledRules:      [920300, 942130],     // skip these rule IDs
    disabledCategories: ['session_fixation'], // skip whole categories
    rulesPath:          null,                 // override location of compiled.php
    severityScores: [
        'critical' => 5,                // what each severity *adds* to the score
        'error'    => 4,
        'warning'  => 3,
        'notice'   => 2,
    ],
    maxRequestBodyBytes:  131072,       // request body bytes handed to the ruleset
    maxResponseBodyBytes: 524288,       // response body bytes — larger, see below
    maxArgs:              255,          // argument values inspected (counting is uncapped)
    maxArgBytes:          131072,       // total argument bytes inspected per rule
    responseMode:         null,         // overrides `mode` outbound only
);
Field Default Notes
paranoia 1 Rules tagged with paranoia-level/N above this are skipped.
mode block monitor evaluates and records matches but never returns block.
anomalyThresholds ['inbound' => 5, 'outbound' => 4] Score at which to block, per direction. There are exactly two thresholds.
disabledRules [] List of CRS rule IDs to skip — useful for known false positives.
disabledCategories [] Skip an entire category for targeted tuning — see Rule categories.
rulesPath bundled rules/ Point at a custom rule directory (used for testing and custom rulesets).
severityScores CRS defaults Anomaly contribution per severity. Upstream exposes these in crs-setup.conf.
requestMode / responseMode follow mode Run the two directions in different modes — see Request and response are configured separately.
maxRequestBodyBytes 131072 Request body bytes inspected. CrsConfig::UNLIMITED to disable.
maxResponseBodyBytes 524288 Response body bytes inspected. Deliberately larger than the request limit.
maxArgs 255 Argument values inspected. Counting is never capped, so &ARGS rules still see the true total.
maxArgBytes 131072 Total argument bytes a single rule inspects. Bounds what a few very large arguments cost.
failClosedOnOperatorError false Treat a request whose evaluation hit an operator error as blocked.

Request and response are configured separately

The two directions do not carry the same traffic and do not warrant the same handling, so the knobs that can differ, do:

new CrsConfig(
    mode:         CrsConfig::MODE_BLOCK,    // reject attacks on the way in
    responseMode: CrsConfig::MODE_MONITOR,  // only record leakage on the way out
);

That pairing is the usual posture for a CMS. Blocking outbound is a far heavier action than blocking inbound: the application has already done its work, and rejecting the response means serving an error in place of a page that is very likely fine. monitor outbound still evaluates every RESPONSE-* rule and still populates matchedRules and totalScore — it just never returns block.

The body limits are split for the same reason. maxRequestBodyBytes defaults to 131072, matching ModSecurity's SecRequestBodyNoFilesLimit; maxResponseBodyBytes defaults to 524288, matching SecResponseBodyLimit. A 128 KB request body is large, whereas a 128 KB HTML page is ordinary — and leaked stack traces and SQL errors tend to appear in the tail of a page, so a request-sized cap applied outbound hid exactly the evidence the response rules exist to find.

Whenever a limit does engage, CrsVerdict::$truncations records what was inspected against what arrived, so reduced coverage is visible rather than inferred.

anomalyThresholds was already directional and is unchanged. maxArgs and maxArgBytes are request-only concepts, since a response has no arguments.

Tuning for a CMS

CRS is tuned for applications, and a CMS is an application whose users paste code into text fields. At PL1 the shipped ruleset flags a fair amount of ordinary editorial content, and that is upstream behaviour rather than an engine defect — but it is the first thing you will hit, so it is worth knowing what to expect before you turn blocking on.

Sweeping twenty realistic benign requests through the default config, four were blocked:

Traffic Rules Why
A post body containing cat /etc/hosts | grep localhost 930120, 932235, 932260 Shell commands in content look exactly like RCE payloads
A regex in a support ticket 932280 Shell metacharacters
A JSON blob pasted into a form field 920540 \uXXXX reads as a Unicode bypass outside a JSON body
A URL path containing ../ 930100, 930110 Genuinely a traversal pattern

None of these have a clever fix. They need exclusions, scoped as tightly as you can manage.

Start here, then narrow. Exclude the specific fields that carry authored content rather than disabling rules globally:

new CrsConfig(
    // Fields where users legitimately paste code, paths and regexes.
    // Prefer this over disabledRules: it keeps the rule working everywhere else.
    disabledRules: [
        932235, 932260,   // unix command injection — trips on shell examples
        932280,           // shell metacharacters — trips on regexes
    ],
);

If only part of your site accepts authored content, run two engine instances with different configs and pick per route. That keeps full strength on your login and checkout paths, where it matters most, and relaxes only the editor.

Watch before you block. mode: MODE_MONITOR evaluates everything and returns log instead of block. Run it over real traffic for a week, group matchedRules by rule id, and you will have a far better exclusion list than any generic one — including this one.

Outbound is already monitor-only by default. See Request and response are configured separately; you do not need to do anything to avoid a docs page being blocked for mentioning fopen.

Feeding the engine correctly

Two integration details change how much the engine can see. Neither is obvious, and both silently reduce detection if missed.

Decode JSON bodies into postArgs. The engine does not parse request bodies — that is the integrator's job, because your framework has already done it. REQUEST_BODY is inspected as a single string, but almost all of the detection rules target ARGS, so an attack in an undecoded JSON body is not seen:

// A JSON API endpoint. Without the decode, ARGS is empty.
$decoded = json_decode($rawBody, true);

new RequestData(
    // ...
    postArgs: is_array($decoded) ? $decoded : [],
    body:     $rawBody,
);
SQLi in a JSON body, not decoded    allow  score=0
SQLi in a JSON body, decoded        block  score=10  rules=[942190,942360,949110]

Set bodyProcessor, or send an accurate Content-Type. CRS gates real behaviour on REQBODY_PROCESSOR: rule 920539 checks it for JSON and switches off 920540, which would otherwise flag every \uXXXX escape as a Unicode bypass. The engine infers the processor from Content-Type when you do not set it explicitly, so an accurate header is usually enough.

Thresholds vs. severity scores

These are the two halves of the anomaly model and are easy to confuse:

  • severityScores is what a matching rule adds. A severity:CRITICAL rule contributes 5 by default.
  • anomalyThresholds is what the running total is compared against. Cross the inbound threshold and CRS rule 949110 blocks the request; cross the outbound one and 959100 blocks the response.

So the defaults mean "block a request once it accumulates one critical-severity detection". Raise inbound to 10 to require two.

Deprecated: anomalyThresholds previously took severity keys, where critical silently meant inbound and error meant outbound, while warning and notice did nothing at all. Those spellings still work and emit E_USER_DEPRECATED; warning/notice are ignored. Unrecognised keys now throw ConfigurationException instead of being silently accepted.

The request DTO

Kanopi\Crs\Request\RequestData is the framework-agnostic input. Build it once per request from whatever your framework provides:

new RequestData(
    method:      'POST',
    uri:         '/api/comments',
    rawUri:      '/api/comments',
    queryString: '',
    protocol:    'HTTP/1.1',
    remoteAddr:  '203.0.113.42',
    queryArgs:   $request->query->all(),         // GET params
    postArgs:    $request->request->all(),       // POST/form params
    cookies:     $request->cookies->all(),
    headers:     $request->headers->all(),       // name => string|string[]
    body:        (string) $request->getContent(),
    files:       [],                              // [{name, filename, mime, size}]
);

RequestData::fromGlobals() is provided for CLI experimentation but should not be used in framework integrations — your framework has a richer, already-parsed request object.

The verdict

CrsEngine::evaluate() returns a Kanopi\Crs\CrsVerdict:

$verdict->action;          // 'allow' | 'log' | 'block'
$verdict->isBlocked();     // bool
$verdict->blockingRuleId;  // ?int — the first rule that fired with deny/block/drop
$verdict->totalScore;      // accumulated anomaly score across paranoia levels
$verdict->scores;          // per-category: ['sqli' => 5, 'xss' => 0, ...]
$verdict->matchedRules;    // [id, msg, severity, score, tags, category, matched_data, logdata]
$verdict->toArray();       // serialisable shape for logging

In monitor mode action is log whenever any rule matched and allow otherwise — isBlocked() always returns false. In block mode, the first rule that asks to deny short-circuits evaluation.

Supported SecLang subset

The parser is deliberately narrower than full ModSecurity. It covers everything CRS 4.x uses in its REQUEST-* rule files, with the explicit exception of operators that need libinjection.

Directives: SecRule (full), SecAction (unconditional — CRS uses it to reset aggregate scores between phases, so it is evaluated, not ignored), and SecMarker (a placeholder that keeps its position so skipAfter has somewhere to land).

Operators (18): @rx, @pm, @pmf, @beginsWith, @endsWith, @contains, @containsWord, @streq, @eq/@gt/@lt/@ge/@le, @within, @ipMatch (with CIDR), @validateByteRange, @validateUrlEncoding, @validateUtf8Encoding.

Unsupported operators (@detectSQLi, @detectXSS, etc.) cause the rule to be parsed-and-skipped with a warning recorded in manifest.json. These two operators back CRS rules 942100 and 941100 specifically — the libinjection-backed SQLi and XSS detectors. The other 50+ SQLi and 40+ XSS rules in CRS are pure @rx and work normally. See Detection coverage for what that costs in practice and what the engine does about it.

Transforms (21): none, lowercase/uppercase, urlDecode/urlDecodeUni, htmlEntityDecode, compressWhitespace/removeWhitespace, replaceNulls/removeNulls, utf8toUnicode, base64Decode/base64DecodeExt, cmdLine, normalisePath, length, sha1/md5, trim, removeComments/replaceComments.

Variables (targets), 52 in total:

RequestARGS, ARGS_GET, ARGS_POST, ARGS_NAMES, ARGS_GET_NAMES, ARGS_POST_NAMES, REQUEST_URI, REQUEST_URI_RAW, REQUEST_FILENAME, REQUEST_BASENAME, REQUEST_METHOD, REQUEST_PROTOCOL, REQUEST_LINE, REQUEST_BODY, REQUEST_HEADERS, REQUEST_HEADERS_NAMES, REQUEST_COOKIES, REQUEST_COOKIES_NAMES, QUERY_STRING, REMOTE_ADDR, UNIQUE_ID, REQBODY_PROCESSOR.

UploadsFILES, FILES_NAMES, FILES_SIZES, FILES_TMPNAMES. FILES reads the upload's tmp_name when readable, or inline content if the integrator pre-read it.

Response, available to evaluateResponse()RESPONSE_STATUS, RESPONSE_PROTOCOL, RESPONSE_HEADERS, RESPONSE_HEADERS_NAMES, RESPONSE_BODY, RESPONSE_CONTENT_TYPE, RESPONSE_CONTENT_LENGTH, OUTBOUND_DATA_ERROR.

XMLXML with an XPath selector, e.g. XML:/*. Parsed lazily, once per request. External entities never resolve; see XmlBody.

MultipartMULTIPART_PART_HEADERS, plus the CRS-922 anti-evasion flags MULTIPART_STRICT_ERROR, MULTIPART_UNMATCHED_BOUNDARY, MULTIPART_BOUNDARY_QUOTED, MULTIPART_BOUNDARY_WHITESPACE, MULTIPART_CRLF_LF_LINES, MULTIPART_DATA_AFTER, MULTIPART_DATA_BEFORE, MULTIPART_FILE_LIMIT_EXCEEDED, MULTIPART_HEADER_FOLDING, MULTIPART_INVALID_HEADER_FOLDING, MULTIPART_INVALID_PART, MULTIPART_INVALID_QUOTING, MULTIPART_LF_LINE, MULTIPART_MISSING_SEMICOLON, MULTIPART_NAME, MULTIPART_SEMICOLON_MISSING. These are only populated if the integrator supplies them on RequestData — the engine does not parse multipart bodies itself.

Engine stateTX:<name>, including TX:/regex/ to read a family of names such as the per-parameter keys setvar generates.

Target modifiers !collection:selector (exclude), &collection (count), and regex selectors collection:/pattern/ are all supported.

Actions: id, phase, chain, msg, severity, tag, t:*, multiMatch, skipAfter, and:

  • setvar%{...} on both sides is expanded per request, so tx.counter_%{MATCHED_VAR_NAME} produces one key per parameter.
  • capture — numbered regex groups are written to TX:0TX:9 before any chained condition runs, which is where CRS reads them.
  • logdata — expanded per request and returned on CrsVerdict::$matchedRules[]['logdata'].
  • block/deny/drop/pass/allow — only deny and drop stop evaluation. block defers to SecDefaultAction, which for CRS detection rules means "score and continue"; see Detection coverage.

ver/rev/maturity/accuracy are parsed and unused. ctl:*, expirevar, deprecatevar, initcol and similar state management are accepted and ignored.

Not implemented: a rule's actions fire once per rule, not once per matching variable. CRS's per-parameter counters (921170/921180, HTTP parameter pollution) need the latter and do not work.

Rule categories

Each rule takes its category from the CRS file it came from. Categories drive disabledCategories and the per-category breakdown in CrsVerdict::$scores.

Request side

Category Source Rules
method_enforcement REQUEST-911 9
scanner REQUEST-913 9
protocol_enforcement REQUEST-920 68
protocol_attack REQUEST-921 26
multipart REQUEST-922 6
lfi REQUEST-930 14
rfi REQUEST-931 13
rce REQUEST-932 55
php REQUEST-933 29
generic REQUEST-934 20
xss REQUEST-941 40
sqli REQUEST-942 + supplemental/ 67
session_fixation REQUEST-943 11
java REQUEST-944 22

Response side

Category Source Rules
response_leak RESPONSE-950 14
response_leak_sql RESPONSE-951 26
response_leak_java RESPONSE-952 10
response_leak_php RESPONSE-953 13
response_leak_iis RESPONSE-954 14
web_shell RESPONSE-955 36
response_leak_ruby RESPONSE-956 11

Both directionsblocking_evaluation (REQUEST-949 + RESPONSE-959, 56) applies the anomaly threshold; disabling it turns off score-based blocking entirely. correlation (RESPONSE-980, 21) is logging only.

Changed: categories used to be derived by matching words in the filename, which collided. rce also covered method and protocol enforcement, because "enforcement" contains "rce"; php and java also covered their response-leak counterparts; and everything unrecognised fell into a misc bucket. If you disable categories, re-check your list: misc no longer exists, rce is now REQUEST-932 only, and protocol_enforcement, method_enforcement, blocking_evaluation, response_leak_java, response_leak_php and response_leak_ruby are new names for rules that were previously filed elsewhere.

Detection coverage

The engine cannot run CRS 942100 and 941100, the two libinjection-backed detectors, and those are the PL1 backbone for SQL injection. Measured against 34 attack payloads and 32 samples of realistic CMS/search traffic:

Paranoia Attacks blocked False positives
1 (default) 30/34 (88%) 0/32 (0%)
2 34/34 (100%) 5/32 (15.6%)
3 34/34 (100%) 10/32 (31%)
4 34/34 (100%) 27/32 (84%)

Raising the default to PL2 is not recommended. The rules that come in at PL2 block ordinary content — markdown post bodies, JSON API payloads, code snippets in comment fields, quoted prose. PL3 additionally blocks accented, CJK and Arabic names. Both are usable, but only with a per-site exclusion list built from real traffic; see disabledRules and disabledCategories.

What the engine adds

supplemental/REQUEST-948-TAUTOLOGY.conf ships one engine-owned rule, 948100, covering the ' OR '1'='1 / OR 1=1 family that libinjection would otherwise catch at PL1. It is parsed alongside CRS by bin/refresh-crs and survives CRS bumps because it lives outside rules/. It is tagged kanopi-crs-engine so it is easy to tell apart from upstream rules, and it can be turned off like any other rule:

new CrsConfig(disabledRules: [948100]);

Its false-positive profile is pinned by tests/Integration/PayloadCorpusTest.php, which fails the build if it starts flagging ordinary prose.

Known gaps at PL1

These need real tokenisation and are deliberately not chased with regex, because every pattern that catches them also catches ordinary content:

Payload Why not Caught at
admin'-- collides with quoted prose using -- PL2
`id` collides with inline code in comment fields PL2

If you need these at PL1, run PL2 with an exclusion list, or open an issue about porting libinjection.

Rule scope

Every REQUEST-*.conf and RESPONSE-*.conf in the pinned CRS release is parsed, plus this engine's own supplemental/:

REQUEST-911-METHOD-ENFORCEMENT      RESPONSE-950-DATA-LEAKAGES
REQUEST-913-SCANNER-DETECTION       RESPONSE-951-DATA-LEAKAGES-SQL
REQUEST-920-PROTOCOL-ENFORCEMENT    RESPONSE-952-DATA-LEAKAGES-JAVA
REQUEST-921-PROTOCOL-ATTACK         RESPONSE-953-DATA-LEAKAGES-PHP
REQUEST-922-MULTIPART-ATTACK        RESPONSE-954-DATA-LEAKAGES-IIS
REQUEST-930-APPLICATION-ATTACK-LFI  RESPONSE-955-WEB-SHELLS
REQUEST-931-APPLICATION-ATTACK-RFI  RESPONSE-956-DATA-LEAKAGES-RUBY
REQUEST-932-APPLICATION-ATTACK-RCE  RESPONSE-959-BLOCKING-EVALUATION
REQUEST-933-APPLICATION-ATTACK-PHP  RESPONSE-980-CORRELATION
REQUEST-934-APPLICATION-ATTACK-GENERIC
REQUEST-941-APPLICATION-ATTACK-XSS        supplemental/
REQUEST-942-APPLICATION-ATTACK-SQLI       REQUEST-948-TAUTOLOGY
REQUEST-943-APPLICATION-ATTACK-SESSION-FIXATION
REQUEST-944-APPLICATION-ATTACK-JAVA
REQUEST-949-BLOCKING-EVALUATION

Request-phase rules (phase 1–2) run on evaluate(); response-phase rules (phase 3–4) run on evaluateResponse(). Phase-5 logging rules are parsed but contribute nothing to a verdict.

Two CRS files are deliberately not parsed: REQUEST-901-INITIALIZATION and REQUEST-905-COMMON-EXCEPTIONS. They are CRS's own configuration scaffolding, normally driven by crs-setup.conf, which this engine replaces with CrsConfig and CrsTxDefaults. Parsing 901 in particular would make rule 901001 deny every request, because it checks a variable crs-setup.conf is supposed to have set.

The current release parses 586 CRS rules plus 1 supplemental rule, 67 chain conditions, 6 SecAction directives and 29 SecMarker placeholders. Four CRS rules are skipped for using @detectSQLi/@detectXSS; the counts are asserted by tests/Integration/RulesetInvariantsTest.php, so they cannot drift silently across a CRS bump.

Refreshing CRS rules

bin/refresh-crs is the single entry point for keeping the rule cache current. It is deliberately not run at runtime — only at build / CI time.

# Use the tag pinned in .crs-version
bin/refresh-crs

# Look up the latest stable CRS release on GitHub, update the pin, parse
bin/refresh-crs --bump

# Pin to a specific tag
bin/refresh-crs --tag=v4.7.0

# Parse but don't overwrite rules/
bin/refresh-crs --dry-run

What it does:

  1. Reads .crs-version (or applies the override flag).
  2. Downloads https://github.com/coreruleset/coreruleset/archive/refs/tags/<tag>.tar.gz.
  3. Extracts to a temp directory with PharData.
  4. Verifies the content digest against the pinunless --bump was passed, which re-pins instead. A mismatch aborts before anything is parsed or written, leaving rules/ untouched.
  5. Parses every supported REQUEST-*.conf with the bundled SecLangParser, plus anything in supplemental/.
  6. Builds the output in a staging directory and swaps it into place only once every file has been written. A failure part-way through leaves the previous rules/ intact and loadable.
  7. Updates .crs-version with the tag and the digest.

The result is normal, reviewable git changes. The intended pattern for production projects is a scheduled CI job that runs --bump weekly, opens a PR with the regenerated rules, and lets a maintainer review the diff before merging. CircleCI's weekly-refresh workflow in this repo demonstrates that pattern.

The automated path performs no verification. Because that job runs --bump, and --bump re-pins rather than checks, the weekly refresh is trust-on-first-use every week — it downloads whatever the source serves, hashes it, records the hash as the new pin, and compares against nothing. That is unavoidable for a version bump, but it means human review of the rules/ diff is the only control on what enters the ruleset. The PR the workflow opens says so explicitly rather than leaving a reviewer to assume the green checkmarks covered it; they cover parsing and tests, not provenance. See Version pin format for what the digest does and does not establish.

Version pin format

.crs-version is a plain key=value file:

tag=v4.28.0
sha=sha256:aac29fbd56288cb37adec9ac879fa694f6aaa410a564bf5b834bd335d0e216df
source=https://github.com/coreruleset/coreruleset

The source field can point at a fork or mirror.

sha is a content digest of the CRS rule files — a sha256 over filename:sha256 for every file in the release's rules/ directory, sorted. It is enforced on a plain bin/refresh-crs: if the rules published under the pinned tag stop matching it, the refresh fails and rules/ is left alone. It covers top-level files only, and a subdirectory in the upstream tree aborts the refresh rather than being silently left outside the digest.

It hashes the extracted files rather than the tarball on purpose. GitHub's /archive/refs/tags/ tarballs are generated on demand and are not guaranteed byte-stable — when GitHub changed its gzip in 2023, every auto-generated archive checksum changed at once and broke everyone pinning them. Since the refresh runs on a schedule, a pin that can fail for reasons unrelated to the content would just train people to ignore it.

Be clear about what this does and does not buy you. It detects ruleset substitution: a re-tagged release, a tampered mirror, an unexpected content change under a tag you have already reviewed. It is not a signature — it does not authenticate the publisher, and it cannot help on the very first fetch, which is trust-on-first-use. If the pin is empty the digest is recorded rather than enforced, and bin/refresh-crs says so:

Content digest recorded (nothing to verify against yet): sha256:9987…
Content digest verified: sha256:9987…

--bump deliberately re-pins, because moving to a new tag is a content change by definition. Review the rules/ diff in the resulting PR — that diff, not the digest, is what tells you what actually changed.

Debugging a rule

bin/crs-explain prints the parsed form of a CRS rule and optionally tests a payload against it:

# Show how rule 942260 is parsed
bin/crs-explain 942260

# Test a payload against it
bin/crs-explain 942260 --payload="' UNION SELECT password FROM users"

Output includes the rule's targets, transforms, operator, message, tags, and whether your payload matches. Useful for diagnosing false positives without trawling through CRS source.

Testing

Two test suites, separated by speed and scope.

# Everything — unit + integration, no network, about a second
composer test

# Just the unit tests
composer test:unit

# Just the integration tests
composer test:integration

# With coverage
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html coverage/

Unit tests (tests/Unit/) cover the parser, transforms, operators, and the TxStore against hand-crafted SecLang snippets — no network, no real CRS download.

Integration tests (tests/Integration/) run real-shaped CRS rules from bundled fixtures (tests/Integration/fixtures/REQUEST-*.conf) against real attack payloads:

  • SQLi: UNION SELECT, ' OR 1=1, DELETE FROM, URL-encoded variants, monitor-mode behavior, disabled-rule behavior.
  • XSS: <script> tags, javascript: URIs, event handlers, HTML-entity- encoded payloads.
  • Refresh flow: parse → write → load round-trip.

The fixture files mirror the format and identifier ranges of real CRS rules, so the integration tests double as regression checks for the parser.

Code quality checks

All three static analysis tools are wired into composer scripts and CI:

# Individual checks
composer check:code      # PHPCS (PSR-12 + PHPCompatibility for PHP 8.1+)
composer check:stan      # PHPStan at level max
composer check:rector    # Rector --dry-run

# All checks at once
composer check

# Auto-fix what's mechanically fixable
composer fix             # Rector + PHPCBF in order

PHPStan runs at level: max. The full firewall library's pragmatic identifier ignores (argument.type, cast.string, missingType.iterableValue) are inherited so untrusted-JSON ingestion paths stay tractable.

Rector targets LevelSetList::UP_TO_PHP_81 so the engine stays compatible with the lower PHP bound while still picking up modern idioms (readonly properties, constructor promotion, etc.).

CircleCI pipeline

.circleci/config.yml mirrors kanopi/firewall — it uses the kanopi/ci-tools@2 orb, runs every check on a PHP-version matrix (8.1, 8.2, 8.3, 8.4, 8.5), and exposes three workflows.

Workflow Trigger Jobs (each runs across the full PHP matrix)
test every push / PR phpunit, phpstan (via check:stan:circleci), rector (check:rector:circleci), quality (check:code:circleci)
weekly-refresh scheduled trigger with pipeline-trigger=weekly-refresh refresh-and-branch (runs refresh-crs --bump, all static checks, all tests, then pushes a chore/crs-refresh-<tag>-<date> branch)
release tag push matching vX.Y.Z Same matrix as test, but gated to tag pushes — green on every PHP version is the prerequisite for the release tag to ship.

Each matrix job publishes JUnit results and stores the per-tool reports (phpcs-report.xml, phpstan-report.xml, rector-report.xml, reports/junit.xml) as CircleCI artifacts.

To wire up the weekly bump:

  1. In CircleCI's project settings, add a Scheduled Pipeline:
    • Schedule: 0 6 * * 1 (Mondays 06:00 UTC)
    • Pipeline parameter: pipeline-trigger = weekly-refresh
  2. Ensure the kanopi-code CircleCI context is attached. It carries the shared deploy SSH key (consumed by ci-tools/copy-ssh-key), the GitHub token for gh pr create, and the Docker Hub credentials.

The job:

  1. Runs bin/refresh-crs --bump to fetch the latest upstream CRS release.
  2. Re-runs phpcs / phpstan / rector / phpunit against the refreshed ruleset.
  3. If anything changed in .crs-version or rules/, commits to a new chore/crs-refresh-<tag>-<date> branch.
  4. Pushes the branch and opens a PR automatically via gh pr create, labelled crs-bump, with rule counts and warning counts in the body.

A maintainer reviews the diff and merges — the only human step.

Project layout

crs-engine/
├── bin/
│   ├── refresh-crs           Download + parse + write CRS rules
│   └── crs-explain           Debug a parsed rule against a payload
├── rules/                    Generated by refresh-crs (gitignored or committed per project policy)
│   ├── compiled.php          Runtime hot path (var_export'd)
│   ├── manifest.json         Version, counts, parser warnings
│   └── REQUEST-*.json        Per-source-file JSON for review
├── supplemental/             Engine-owned SecLang, parsed alongside CRS and
│                             not clobbered by a refresh — see Detection coverage
├── src/
│   ├── CrsEngine.php         Public entry point
│   ├── CrsConfig.php
│   ├── CrsVerdict.php
│   ├── Exception/
│   ├── Body/XmlBody.php      Lazy XML body parser for XML: targets
│   ├── Operators/            18 SecLang operators + registry + PhraseSet
│   ├── Parser/               SecLang parser + DTOs
│   ├── Refresh/              CrsFetcher, RuleWriter, VersionPin, RulesetDigest,
│   │                         RefreshRunner, CrsSource
│   ├── Request/              RequestData + ResponseData DTOs
│   ├── Runtime/              RuleEvaluator, RuleSet, TxStore, TransformPipeline,
│   │                         CrsTxDefaults, CompiledRule
│   ├── Transforms/           21 SecLang transforms + registry
│   └── Variables/            VariableResolver (52 targets)
├── tests/
│   ├── Integration/          Against the real bundled ruleset:
│   │   ├── fixtures/         CRS-shaped .conf files for the narrower tests
│   │   ├── PayloadCorpusTest.php        detection rate + false positives
│   │   ├── RulesetInvariantsTest.php    rule counts, markers, TX contract
│   │   ├── DisabledCategoryScopeTest.php
│   │   └── RefreshFailureModesTest.php  digest + atomic write
│   └── Unit/
│       ├── Body/
│       ├── Operators/
│       ├── Parser/
│       ├── Runtime/
│       ├── Transforms/
│       └── Variables/
├── .circleci/config.yml
├── .crs-version              Pinned upstream CRS tag + content digest
├── composer.json
├── phpcs_ruleset.xml         PSR-12 + PHPCompatibility 8.1+
├── phpstan.neon              level: max
└── rector.php                UP_TO_PHP_81 + standard set list

Versioning

The engine follows semver, with CRS pin bumps driving the change type:

  • Patch (v0.1.1v0.1.2): engine bug fix, no parser/runtime API change, no CRS bump.
  • Minor (v0.1.xv0.2.0): CRS bump (any), new operators or transforms, parser improvements that are strictly additive.
  • Major (v0.xv1.0): breaking change to CrsEngine / CrsConfig / CrsVerdict / RequestData public API.

Each release commit carries the CRS tag it ships with in its message, e.g. Release v0.3.0 (CRS v4.7.0). The shipped .crs-version is authoritative.

License and attribution

The engine code is licensed under the MIT License (see composer.json).

CRS rule content under rules/ is a derived work of the OWASP Core Rule Set, which is licensed under Apache 2.0. The CRS NOTICE and LICENSE files are copied into rules/ on every refresh; please retain them when redistributing.

This package does not vendor or republish CRS — it downloads it on demand during the refresh step.