A powerful linting tool for OWASP CRS configs
The CRS Linter helps maintain code quality and consistency across your rule configurations by automatically checking for common issues, style violations, and best practices.
- Prerequisites
- Installation
- Quick Start
- Command Line Arguments
- Output Formats
- Linting Rules Reference
Python 3.7+ is required to run this tool.
Install using PyPi:
pip3 install crs-linterThe basic usage requires three main arguments:
crs-linter \
-d /path/to/coreruleset \
-r crs-setup.conf.example \
-r 'rules/*.conf' \
-t util/APPROVED_TAGSHere's a full example with all recommended options (run from the coreruleset directory):
../crs-linter/src/crs_linter/cli.py \
--debug \
-r crs-setup.conf.example \
-r 'rules/*.conf' \
-t util/APPROVED_TAGS \
-f ../crs-linter/FILENAME_EXCLUSIONS \
-v "4.13.0-dev"| Argument | Description |
|---|---|
-d, --directory |
Path to the CRS git repository (required if version is not provided) |
-r, --rules |
CRS rules file(s) to check (can be used multiple times). Supports glob patterns like 'rules/*.conf' |
-t, --tags-list |
Path to the approved tags file. Tags not in this file will trigger validation errors |
| Argument | Description |
|---|---|
-h, --help |
Show usage information and exit |
-o, --output |
Output format: native (default) or github |
--debug |
Enable debug information output |
-v, --version |
CRS version string (auto-detected if not provided) |
-f, --filename-tags-exclusions |
Path to file containing filenames exempt from filename tag checks |
-T, --tests |
Path to test files directory |
-E, --filename-tests-exclusions |
Path to file with rule ID prefixes excluded from test coverage checks |
--head-ref |
Git HEAD ref from CI pipeline (helps determine version) |
--commit-message |
PR commit message from CI (helps determine version for release commits) |
To see all available options:
crs-linter -hExample output:
usage: crs-linter [-h] [-o {native,github}] -d DIRECTORY [--debug] -r CRS_RULES -t TAGSLIST [-v VERSION] [--head-ref HEAD_REF] [--commit-message COMMIT_MESSAGE]
[-f FILENAME_TAGS_EXCLUSIONS] [-T TESTS] [-E FILENAME_TESTS_EXCLUSIONS]
crs-linter: error: the following arguments are required: -d/--directory, -r/--rules, -t/--tags-listStandard human-readable output format:
crs-linter -d /path/to/crs -r 'rules/*.conf' -t util/APPROVED_TAGSSpecially formatted output for GitHub Actions workflows with ::debug and ::error prefixes:
crs-linter \
--output=github \
-d /path/to/crs \
-r 'rules/*.conf' \
-t util/APPROVED_TAGSThis format follows GitHub's workflow commands specification for better CI/CD integration.
This section is automatically generated from the Python docstrings in src/crs_linter/rules/.
π‘ To update this documentation: Edit the docstrings in the rule class files and run
python generate_rules_docs.py.
Source: src/crs_linter/rules/approved_tags.py
Check that only tags from the util/APPROVED_TAGS file are used.
This rule verifies that all tags used in rules are registered in the util/APPROVED_TAGS file. Any tag not listed in this file will be considered a check failure.
Example of a failing rule:
SecRule REQUEST_URI "@rx index.php" \
"id:1,\
phase:1,\
deny,\
t:none,\
nolog,\
tag:attack-xss,\
tag:my-custom-tag" # Fails if 'my-custom-tag' not in APPROVED_TAGSTo use a new tag on a rule, it must first be registered in the util/APPROVED_TAGS file.
Source: src/crs_linter/rules/check_capture.py
Check that rules using TX.N variables have a corresponding capture action.
This rule ensures that captured transaction variables (TX:0, TX:1, TX:2, etc.)
are only used when a capture action has been defined in the rule chain.
TX.N variables can be referenced in multiple ways:
- As a rule target:
SecRule TX:1 "@eq attack" - In action arguments:
msg:'Matched: %{TX.1}',logdata:'Data: %{TX.0}' - In operator arguments:
@rx %{TX.1} - In setvar assignments:
setvar:tx.foo=%{TX.1}
Example of a passing rule (with capture):
SecRule ARGS "@rx (attack)" \
"id:2,\
phase:2,\
deny,\
capture,\
msg:'Attack detected: %{TX.1}',\
logdata:'Pattern: %{TX.0}',\
chain"
SecRule TX:1 "@eq attack"Example of a failing rule (missing capture for target):
SecRule ARGS "@rx attack" \
"id:3,\
phase:2,\
deny,\
t:none,\
nolog,\
chain"
SecRule TX:0 "@eq attack" # Fails: uses TX:0 without captureExample of a failing rule (missing capture for action argument):
SecRule ARGS "@rx attack" \
"id:4,\
phase:2,\
deny,\
msg:'Matched: %{TX.1}'" # Fails: references TX.1 without captureThis check addresses the issue found in CRS PR #4265 where %{TX.N} was used in action arguments without verifying that capture was defined.
Source: src/crs_linter/rules/crs_tag.py
Check that every rule has a tag:'OWASP_CRS' action and a tag for its filename.
This rule verifies that:
- Every rule has a tag with value 'OWASP_CRS'
- Every non-administrative rule has a tag with value 'OWASP_CRS/$filename$'
Example of a failing rule (missing OWASP_CRS tag):
SecRule REQUEST_URI "@rx index.php" \
"id:1,\
phase:1,\
deny,\
t:none,\
nolog,\
tag:attack-xss" # Fails: missing tag:OWASP_CRSExample of a passing rule:
SecRule REQUEST_URI "@rx index.php" \
"id:1,\
phase:1,\
deny,\
t:none,\
nolog,\
tag:OWASP_CRS,\
tag:OWASP_CRS/test11"Files can be excluded from filename tag checking using the -f flag with a list of excluded files (see FILENAME_EXCLUSIONS for an example).
Source: src/crs_linter/rules/deprecated.py
Check for deprecated patterns in rules.
This is a general-purpose rule for checking deprecated patterns that may be removed in future CRS versions. Currently checks for ctl:auditLogParts.
Example of a failing rule (using deprecated ctl:auditLogParts):
SecRule TX:sql_error_match "@eq 1" \
"id:1,\
phase:4,\
block,\
capture,\
t:none,\
ctl:auditLogParts=+E" # Fails: ctl:auditLogParts is deprecatedThe ctl:auditLogParts action is no longer supported in CRS (see PR #3034).
Note: This overlaps with ctl_audit_log.py which checks the same pattern but treats it as "not allowed" rather than "deprecated". Consider consolidating these rules if they serve the same purpose.
Source: src/crs_linter/rules/ignore_case.py
Check the ignore cases at operators, actions, transformations and ctl arguments.
This rule verifies that operators, actions, transformations, and ctl arguments use the proper case-sensitive format. CRS requires specific casing for these elements even though ModSecurity itself may be case- insensitive. This rule also ensures that an operator is explicitly specified.
Example of a failing rule (incorrect operator case):
SecRule REQUEST_URI "@beginswith /index.php" \
"id:1,\
phase:1,\
deny,\
t:none,\
nolog" # Fails: @beginswith should be @beginsWithExample of a failing rule (missing operator):
SecRule REQUEST_URI "index.php" \
"id:1,\
phase:1,\
deny,\
t:none,\
nolog" # Fails: empty operator isn't allowed, must use @rxModSecurity defaults to @rx when no operator is specified, but CRS requires explicit operators for clarity.
Source: src/crs_linter/rules/lowercase_ignorecase.py
Check for combined transformation and ignorecase patterns.
This rule detects when rules use both the t:lowercase transformation and the (?i) case-insensitive regex flag together. This combination is redundant and should be avoided - use one or the other.
Example of a failing rule (combining t:lowercase and (?i)):
SecRule ARGS "@rx (?i)foo" \
"id:1,\
phase:1,\
pass,\
t:lowercase,\ # Fails: redundant with (?i) flag
nolog"The rule should use either:
- t:lowercase with a case-sensitive regex: "@rx foo"
- (?i) flag without t:lowercase transformation
Source: src/crs_linter/rules/pl_consistency.py
Check the paranoia-level consistency.
This rule verifies that rules activated for a specific paranoia level (PL) have consistent tags and anomaly scoring variables. It checks:
- Rules on PL N must have tag 'paranoia-level/N'
- Rules must not have paranoia-level tag if they have 'nolog' action
- Anomaly score variables must match the current PL (e.g., pl1 for PL1)
- Severity must match the anomaly score variable being set
- Rules must have severity action when setting anomaly scores
Example of failing rules:
# Rule activated on PL1 but tagged as PL2
SecRule REQUEST_HEADERS:Content-Length "!@rx ^\d+$" \
"id:920160,\
phase:1,\
block,\
t:none,\
tag:'paranoia-level/2',\ # Wrong: should be paranoia-level/1
severity:'CRITICAL',\
setvar:'tx.inbound_anomaly_score_pl1=+%{tx.error_anomaly_score}'"
# Also wrong: severity CRITICAL but using error_anomaly_score# Rule missing severity action
SecRule REQUEST_HEADERS:Content-Length "!@rx ^\d+$" \
"id:920161,\
phase:1,\
block,\
t:none,\
tag:'paranoia-level/1',\
setvar:'tx.inbound_anomaly_score_pl1=+%{tx.error_anomaly_score}'"
# Missing severity action# Rule setting wrong PL variable
SecRule REQUEST_HEADERS:Content-Length "!@rx ^\d+$" \
"id:920162,\
phase:1,\
block,\
t:none,\
tag:'paranoia-level/1',\
severity:'CRITICAL',\
setvar:'tx.inbound_anomaly_score_pl2=+%{tx.critical_anomaly_score}'"
# Wrong: using pl2 variable on PL1Source: src/crs_linter/rules/standalone_txn.py
Check that TX.N variables are not used as targets in standalone rules.
This rule prevents TX.N capture group variables (TX:0, TX:1, TX:2, etc.) from being used as rule targets in standalone rules. Standalone rules have no control over what values exist in TX.N from previous unrelated rules, making such usage unpredictable and error-prone.
TX.N variables should only be used in chained rules where the parent rule in the chain sets the value (typically via regex matching).
Example of a failing rule (standalone rule using TX.N):
SecRule "TX:4" "@eq 1" \
"id:3,\
phase:2,\
deny" # Fails: standalone rule using TX:4Example of a passing rule (chained rule using TX.N):
SecRule ARGS "@rx (ab|cd)?(ef)" \
"id:1,\
phase:2,\
deny,\
chain"
SecRule "TX:1" "@eq ef" # OK: TX:1 used in chained ruleNote: This check complements the CheckCapture rule, which ensures that TX.N usage requires a capture action. This rule specifically addresses the issue of TX.N in standalone rules where values are unpredictable.
Source: src/crs_linter/rules/variables_usage.py
Check if a used TX variable has been set.
This rule ensures that all TX variables are initialized before they are used. A variable is considered "used" when it appears:
- As a target in a rule (e.g., SecRule TX:foo ...)
- In an operator argument (e.g., @rx %{TX.foo})
- As a right-hand side value in setvar (e.g., setvar:tx.bar=%{tx.foo})
- In an expansion (e.g., msg:'Value: %{tx.foo}')
Example of failing rules (uninitialized variable):
SecRule TX:foo "@rx bar" \
"id:1001,\
phase:1,\
pass,\
nolog" # Fails: TX:foo used but never setSecRule ARGS "@rx ^.*$" \
"id:1002,\
phase:1,\
pass,\
nolog,\
setvar:tx.bar=1" # Warning: tx.bar set but never usedThe linter also reports unused TX variables - variables that are set but never referenced anywhere in the ruleset.
Source: src/crs_linter/rules/version.py
Check that every rule has a ver action with the correct version.
This rule verifies that all rules have a 'ver' action with the correct CRS version string. The version can be specified manually using the -v flag, or automatically extracted from git tags using 'git describe --tags'.
Example of failing rules:
# Missing 'ver' action
SecRule REQUEST_URI "@rx index.php" \
"id:1,\
phase:1,\
deny,\
t:none,\
nolog,\
tag:OWASP_CRS" # Fails: no ver action# Incorrect 'ver' value
SecRule REQUEST_URI "@rx index.php" \
"id:2,\
phase:1,\
deny,\
t:none,\
nolog,\
tag:OWASP_CRS,\
ver:OWASP_CRS/1.0.0-dev" # Fails if expected version is 4.6.0-devExample of a correct rule:
SecRule REQUEST_URI "@rx index.php" \
"id:3,\
phase:1,\
deny,\
t:none,\
nolog,\
tag:OWASP_CRS,\
ver:'OWASP_CRS/4.6.0-dev'"