44
55
66class CheckCapture (Rule ):
7- """Check that every chained rule has a `capture` action if it uses TX.N variable .
7+ """Check that rules using TX.N variables have a corresponding `capture` action.
88
9- This rule ensures that chained rules using captured transaction variables
10- (TX:0, TX:1, TX:2, etc.) have a corresponding `capture` action in a
11- previous rule in the chain.
9+ This rule ensures that captured transaction variables (TX:0, TX:1, TX:2, etc.)
10+ are only used when a `capture` action has been defined in the rule chain.
1211
13- Example of a passing rule:
14- SecRule ARGS "@rx attack" \\
12+ TX.N variables can be referenced in multiple ways:
13+ 1. As a rule target: `SecRule TX:1 "@eq attack"`
14+ 2. In action arguments: `msg:'Matched: %{TX.1}'`, `logdata:'Data: %{TX.0}'`
15+ 3. In operator arguments: `@rx %{TX.1}`
16+ 4. In setvar assignments: `setvar:tx.foo=%{TX.1}`
17+
18+ Example of a passing rule (with capture):
19+ SecRule ARGS "@rx (attack)" \\
1520 "id:2,\\
1621 phase:2,\\
1722 deny,\\
1823 capture,\\
19- t:none ,\\
20- nolog ,\\
24+ msg:'Attack detected: %{TX.1}' ,\\
25+ logdata:'Pattern: %{TX.0}' ,\\
2126 chain"
2227 SecRule TX:1 "@eq attack"
2328
24- Example of a failing rule (missing capture):
29+ Example of a failing rule (missing capture for target ):
2530 SecRule ARGS "@rx attack" \\
2631 "id:3,\\
2732 phase:2,\\
2833 deny,\\
2934 t:none,\\
3035 nolog,\\
3136 chain"
32- SecRule TX:0 "@eq attack" # Fails: uses TX:0 without prior capture
37+ SecRule TX:0 "@eq attack" # Fails: uses TX:0 without capture
38+
39+ Example of a failing rule (missing capture for action argument):
40+ SecRule ARGS "@rx attack" \\
41+ "id:4,\\
42+ phase:2,\\
43+ deny,\\
44+ msg:'Matched: %{TX.1}'" # Fails: references TX.1 without capture
45+
46+ This check addresses the issue found in CRS PR #4265 where %{TX.N} was
47+ used in action arguments without verifying that capture was defined.
3348 """
3449
3550 def __init__ (self ):
@@ -40,65 +55,118 @@ def __init__(self):
4055 self .error_title = "capture is missing"
4156 self .args = ("data" ,)
4257
58+ # Regex patterns for detecting TX.N references
59+ self .target_pattern = re .compile (r"^\d$" ) # For target variables: TX:1
60+ self .expansion_pattern = re .compile (
61+ r"%\{TX[.:](\d)\}" , # Matches %{TX.0} or %{TX:1}
62+ re .IGNORECASE
63+ )
64+
65+ # Actions that commonly use variable expansion
66+ self .actions_to_check = {
67+ 'msg' , 'logdata' , 'setvar' , 'tag'
68+ }
69+
4370 def check (self , data ):
4471 """
45- check that every chained rule has a `capture` action if it uses TX.N variable
72+ Check that TX.N variables are only used when capture action is defined.
73+
74+ This checks for TX.N references in:
75+ - Rule targets (existing functionality)
76+ - Action arguments (msg, logdata, setvar, tag)
77+ - Operator arguments
4678 """
4779 chained = False
4880 ruleid = 0
4981 chainlevel = 0
5082 capture_level = None
51- re_number = re .compile (r"^\d$" )
5283 has_capture = False
5384 use_captured_var = False
85+ use_captured_var_in_expansion = False # Track if TX.N is used in expansion
5486 captured_var_chain_level = 0
87+
5588 for d in data :
5689 # only the SecRule object is relevant
57- if d ["type" ].lower () == "secrule" :
58- for v in d ["variables" ]:
59- if v ["variable" ].lower () == "tx" and re_number .match (
60- v ["variable_part" ]
61- ):
62- # only the first occurrence required
63- if not use_captured_var :
64- use_captured_var = True
65- captured_var_chain_level = chainlevel
66- if "actions" in d :
67- if not chained :
68- ruleid = 0
69- chainlevel = 0
70- else :
71- chained = False
72- for a in d ["actions" ]:
73- if a ["act_name" ] == "id" :
74- ruleid = int (a ["act_arg" ])
75- if a ["act_name" ] == "chain" :
76- chained = True
77- chainlevel += 1
78- if a ["act_name" ] == "capture" :
79- capture_level = chainlevel
80- has_capture = True
81- if ruleid > 0 and not chained : # end of chained rule
82- if use_captured_var :
83- # we allow if target with TX:N is in the first rule
84- # of a chained rule without 'capture'
85- if captured_var_chain_level > 0 :
86- if (
87- not has_capture
88- or captured_var_chain_level < capture_level
89- ):
90- yield LintProblem (
91- line = a ["lineno" ],
92- end_line = a ["lineno" ],
93- desc = f"rule uses TX.N without capture; rule id: { ruleid } " ,
94- rule = "capture" ,
95- )
96- # clear variables
97- chained = False
98- chainlevel = 0
99- has_capture = False
100- capture_level = 0
101- captured_var_chain_level = 0
102- use_captured_var = False
103- ruleid = 0
90+ if d ["type" ].lower () != "secrule" :
91+ continue
92+
93+ # Check 1: TX.N as target variable (existing check)
94+ for v in d ["variables" ]:
95+ if (v ["variable" ].lower () == "tx" and
96+ self .target_pattern .match (v ["variable_part" ])):
97+ # only the first occurrence required
98+ if not use_captured_var :
99+ use_captured_var = True
100+ captured_var_chain_level = chainlevel
101+
102+ # Check 2: TX.N in operator arguments
103+ if "operator_argument" in d and d ["operator_argument" ]:
104+ op_arg = d ["operator_argument" ]
105+ if self .expansion_pattern .search (op_arg ):
106+ if not use_captured_var :
107+ use_captured_var = True
108+ use_captured_var_in_expansion = True
109+ captured_var_chain_level = chainlevel
110+
111+ # Check 3: TX.N in action arguments
112+ if "actions" in d :
113+ if not chained :
114+ ruleid = 0
115+ chainlevel = 0
116+ else :
117+ chained = False
118+
119+ for a in d ["actions" ]:
120+ if a ["act_name" ] == "id" :
121+ ruleid = int (a ["act_arg" ])
122+ if a ["act_name" ] == "chain" :
123+ chained = True
124+ chainlevel += 1
125+ if a ["act_name" ] == "capture" :
126+ capture_level = chainlevel
127+ has_capture = True
128+
129+ # Check if action argument contains TX.N reference
130+ if (a ["act_name" ] in self .actions_to_check and
131+ a .get ("act_arg" )):
132+ if self .expansion_pattern .search (a ["act_arg" ]):
133+ if not use_captured_var :
134+ use_captured_var = True
135+ use_captured_var_in_expansion = True
136+ captured_var_chain_level = chainlevel
137+
138+ # End of rule/chain - validate
139+ if ruleid > 0 and not chained :
140+ if use_captured_var :
141+ # Rules for requiring capture:
142+ # 1. TX.N as target in chained rule (not first) - require capture (original check)
143+ # 2. TX.N in expansion (%{TX.N}) anywhere - require capture (new check for issue #69)
144+ should_error = False
145+
146+ if captured_var_chain_level > 0 :
147+ # TX.N used in a chained rule (not the first)
148+ if not has_capture or captured_var_chain_level < capture_level :
149+ should_error = True
150+ elif use_captured_var_in_expansion and not has_capture :
151+ # TX.N used in expansion (%{TX.N}) without capture
152+ # This is the new check for issue #69
153+ should_error = True
154+
155+ if should_error :
156+ yield LintProblem (
157+ line = a ["lineno" ],
158+ end_line = a ["lineno" ],
159+ desc = f"rule uses TX.N without capture; rule id: { ruleid } " ,
160+ rule = "capture" ,
161+ )
162+
163+ # clear variables
164+ chained = False
165+ chainlevel = 0
166+ has_capture = False
167+ capture_level = 0
168+ captured_var_chain_level = 0
169+ use_captured_var = False
170+ use_captured_var_in_expansion = False
171+ ruleid = 0
104172
0 commit comments