Skip to content

Commit 4845c37

Browse files
committed
tests: add comprehensive tests
Signed-off-by: Felipe Zipitria <[email protected]>
1 parent c62c59b commit 4845c37

5 files changed

Lines changed: 2613 additions & 0 deletions

File tree

tests/conftest.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""
2+
Pytest configuration and shared fixtures for secrules_parsing tests
3+
4+
This module provides common fixtures and helper functions used across all test files.
5+
"""
6+
import pytest
7+
from typing import Any, Dict, List, Optional, Union
8+
from secrules_parsing import parser
9+
10+
11+
# ============================================================================
12+
# Parser Fixtures
13+
# ============================================================================
14+
15+
@pytest.fixture(scope="session")
16+
def secrules_parser():
17+
"""
18+
Provide the secrules parser module as a fixture.
19+
20+
Usage:
21+
def test_something(secrules_parser):
22+
result = secrules_parser.process_from_str("SecRule ARGS ...")
23+
"""
24+
return parser
25+
26+
27+
# ============================================================================
28+
# Helper Functions (available as imports, not fixtures)
29+
# ============================================================================
30+
31+
def parse_rule(rule_text: str) -> Union[Any, Dict[str, Any]]:
32+
"""
33+
Parse a rule text and return the parsed result.
34+
35+
Returns the parsed model object on success, or a dict with error info on failure.
36+
37+
Args:
38+
rule_text: ModSecurity rule text to parse
39+
40+
Returns:
41+
Parsed model object or dict with keys: 'line', 'col', 'message'
42+
43+
Example:
44+
result = parse_rule('SecRule ARGS "@rx attack" "id:1,deny"')
45+
if isinstance(result, dict):
46+
# Parse error
47+
print(f"Error: {result['message']}")
48+
else:
49+
# Success
50+
for rule in result.rules:
51+
print(rule.__class__.__name__)
52+
"""
53+
return parser.process_from_str(rule_text)
54+
55+
56+
def assert_parse_success(parsed_result: Union[Any, Dict]) -> None:
57+
"""
58+
Assert that parsing was successful (not a dict error response).
59+
60+
Args:
61+
parsed_result: Result from parse_rule() or parser.process_from_str()
62+
63+
Raises:
64+
AssertionError: If parsing failed
65+
66+
Example:
67+
result = parse_rule(rule_text)
68+
assert_parse_success(result)
69+
# Now safe to use result.rules
70+
"""
71+
assert not isinstance(parsed_result, dict), \
72+
f"Parse failed: {parsed_result.get('message', 'Unknown error')} at line {parsed_result.get('line', '?')}"
73+
74+
75+
def get_rules_by_type(parsed_result: Any, rule_type: str) -> List[Any]:
76+
"""
77+
Get all rules of a specific type from parsed result.
78+
79+
Args:
80+
parsed_result: Parsed model object
81+
rule_type: Rule type name (e.g., "SecRule", "SecAction", "SecMarker")
82+
83+
Returns:
84+
List of rules matching the specified type
85+
86+
Example:
87+
result = parse_rule(rule_text)
88+
sec_rules = get_rules_by_type(result, "SecRule")
89+
assert len(sec_rules) == 2
90+
"""
91+
if isinstance(parsed_result, dict):
92+
return []
93+
return [rule for rule in parsed_result.rules if rule.__class__.__name__ == rule_type]
94+
95+
96+
def find_action_by_attribute(rule: Any, attr_name: str, attr_value: Any = None) -> Optional[Any]:
97+
"""
98+
Find an action in a rule by attribute name and optionally value.
99+
100+
Args:
101+
rule: Parsed rule object
102+
attr_name: Attribute name to search for (e.g., 'id', 'msg', 'severity')
103+
attr_value: Optional value to match (if None, just checks attribute exists)
104+
105+
Returns:
106+
First matching action or None
107+
108+
Example:
109+
rule = get_rules_by_type(result, "SecRule")[0]
110+
id_action = find_action_by_attribute(rule, 'id', 1000)
111+
assert id_action.id == 1000
112+
"""
113+
if not hasattr(rule, 'actions'):
114+
return None
115+
116+
for action in rule.actions:
117+
if hasattr(action, attr_name):
118+
if attr_value is None:
119+
return action
120+
if getattr(action, attr_name) == attr_value:
121+
return action
122+
return None
123+
124+
125+
def count_actions_by_attribute(rule: Any, attr_name: str, attr_value: Any = None) -> int:
126+
"""
127+
Count actions in a rule matching an attribute name and optionally value.
128+
129+
Args:
130+
rule: Parsed rule object
131+
attr_name: Attribute name to search for
132+
attr_value: Optional value to match
133+
134+
Returns:
135+
Number of matching actions
136+
137+
Example:
138+
rule = get_rules_by_type(result, "SecRule")[0]
139+
tag_count = count_actions_by_attribute(rule, 'tag')
140+
assert tag_count == 3
141+
"""
142+
if not hasattr(rule, 'actions'):
143+
return 0
144+
145+
count = 0
146+
for action in rule.actions:
147+
if hasattr(action, attr_name):
148+
if attr_value is None or getattr(action, attr_name) == attr_value:
149+
count += 1
150+
return count
151+
152+
153+
def has_transformation(rule: Any, transformation: str) -> bool:
154+
"""
155+
Check if a rule has a specific transformation.
156+
157+
Args:
158+
rule: Parsed rule object
159+
transformation: Transformation name (e.g., 'lowercase', 'urlDecode')
160+
161+
Returns:
162+
True if the rule has the transformation
163+
164+
Example:
165+
rule = get_rules_by_type(result, "SecRule")[0]
166+
assert has_transformation(rule, 'lowercase')
167+
"""
168+
if not hasattr(rule, 'actions'):
169+
return False
170+
171+
for action in rule.actions:
172+
if hasattr(action, 'transformations') and action.transformations:
173+
if transformation in action.transformations:
174+
return True
175+
return False
176+
177+
178+
# ============================================================================
179+
# Sample Rule Fixtures
180+
# ============================================================================
181+
182+
@pytest.fixture(scope="function")
183+
def sample_secrule_basic():
184+
"""Simple SecRule for basic testing"""
185+
return 'SecRule ARGS "@rx attack" "id:1,phase:2,deny"'
186+
187+
188+
@pytest.fixture(scope="function")
189+
def sample_secrule_complex():
190+
"""Complex SecRule with multiple actions and transformations"""
191+
return """
192+
SecRule REQUEST_COOKIES|!REQUEST_COOKIES:/__utm/|REQUEST_COOKIES_NAMES|ARGS_NAMES|ARGS|XML:/* "@contains -->" \
193+
"id:941181,\
194+
phase:2,\
195+
block,\
196+
capture,\
197+
t:none,t:utf8toUnicode,t:urlDecodeUni,t:htmlEntityDecode,t:jsDecode,t:cssDecode,t:lowercase,t:removeNulls,\
198+
msg:'Node-Validator Deny List Keywords',\
199+
logdata:'Matched Data: %{TX.0} found within %{MATCHED_VAR_NAME}: %{MATCHED_VAR}',\
200+
tag:'application-multi',\
201+
tag:'language-multi',\
202+
tag:'platform-multi',\
203+
tag:'attack-xss',\
204+
tag:'paranoia-level/2',\
205+
tag:'OWASP_CRS',\
206+
tag:'capec/1000/152/242',\
207+
ctl:auditLogParts=+E,\
208+
ver:'OWASP_CRS/4.0.0-rc1',\
209+
severity:'CRITICAL',\
210+
setvar:'tx.xss_score=+%{tx.critical_anomaly_score}',\
211+
setvar:'tx.inbound_anomaly_score_pl2=+%{tx.critical_anomaly_score}'"
212+
"""
213+
214+
215+
@pytest.fixture(scope="function")
216+
def sample_secaction():
217+
"""Simple SecAction for testing"""
218+
return 'SecAction "id:1000,phase:1,pass,setvar:tx.test=value"'
219+
220+
221+
@pytest.fixture(scope="function")
222+
def sample_secmarker():
223+
"""Simple SecMarker for testing"""
224+
return 'SecMarker BEGIN-TEST-RULES'
225+
226+
227+
# ============================================================================
228+
# Legacy Fixtures (kept for backward compatibility)
229+
# ============================================================================
230+
231+
@pytest.fixture(scope="function")
232+
def contains_rule():
233+
"""Legacy fixture - use sample_secrule_complex instead"""
234+
return """
235+
SecRule REQUEST_COOKIES|!REQUEST_COOKIES:/__utm/|REQUEST_COOKIES_NAMES|ARGS_NAMES|ARGS|XML:/* "@contains -->" \
236+
"id:941181,\
237+
phase:2,\
238+
block,\
239+
capture,\
240+
t:none,t:utf8toUnicode,t:urlDecodeUni,t:htmlEntityDecode,t:jsDecode,t:cssDecode,t:lowercase,t:removeNulls,\
241+
msg:'Node-Validator Deny List Keywords',\
242+
logdata:'Matched Data: %{TX.0} found within %{MATCHED_VAR_NAME}: %{MATCHED_VAR}',\
243+
tag:'application-multi',\
244+
tag:'language-multi',\
245+
tag:'platform-multi',\
246+
tag:'attack-xss',\
247+
tag:'paranoia-level/2',\
248+
tag:'OWASP_CRS',\
249+
tag:'capec/1000/152/242',\
250+
ctl:auditLogParts=+E,\
251+
ver:'OWASP_CRS/4.0.0-rc1',\
252+
severity:'CRITICAL',\
253+
setvar:'tx.xss_score=+%{tx.critical_anomaly_score}',\
254+
setvar:'tx.inbound_anomaly_score_pl2=+%{tx.critical_anomaly_score}'"
255+
"""
256+
257+
258+
@pytest.fixture(scope="package")
259+
def test_template():
260+
"""Test template for generating test cases"""
261+
return """
262+
---
263+
meta:
264+
author: "secrules_parser"
265+
description: Automatically generated
266+
enabled: true
267+
name: test.yaml
268+
tests:
269+
- test_title: {{ test_title }}
270+
desc: {{ desc }}
271+
stages:
272+
- stage:
273+
input:
274+
dest_addr: 127.0.0.1
275+
headers:
276+
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
277+
Host: localhost
278+
User-Agent: OWASP CRS
279+
method: {{ method }}
280+
port: 80
281+
uri: "{{ uri }}
282+
output:
283+
log_contains: id "{{ id }}"
284+
"""

0 commit comments

Comments
 (0)