|
| 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