|
8 | 8 | import celpy |
9 | 9 | from octorules.linter.engine import LintResult, Severity, is_always_false, is_always_true |
10 | 10 |
|
| 11 | +# Rule IDs emitted by validate_rules() — kept in sync with _rules.py by |
| 12 | +# test_plugin_rule_ids_match_metas. |
| 13 | +RULE_IDS: frozenset[str] = frozenset( |
| 14 | + { |
| 15 | + "GA001", |
| 16 | + "GA002", |
| 17 | + "GA003", |
| 18 | + "GA004", |
| 19 | + "GA005", |
| 20 | + "GA020", |
| 21 | + "GA100", |
| 22 | + "GA101", |
| 23 | + "GA102", |
| 24 | + "GA103", |
| 25 | + "GA104", |
| 26 | + "GA105", |
| 27 | + "GA108", |
| 28 | + "GA200", |
| 29 | + "GA201", |
| 30 | + "GA300", |
| 31 | + "GA301", |
| 32 | + "GA302", |
| 33 | + "GA303", |
| 34 | + "GA304", |
| 35 | + "GA305", |
| 36 | + "GA306", |
| 37 | + "GA307", |
| 38 | + "GA310", |
| 39 | + "GA311", |
| 40 | + "GA312", |
| 41 | + "GA313", |
| 42 | + "GA314", |
| 43 | + "GA315", |
| 44 | + "GA316", |
| 45 | + "GA317", |
| 46 | + "GA318", |
| 47 | + "GA319", |
| 48 | + "GA320", |
| 49 | + "GA325", |
| 50 | + "GA326", |
| 51 | + "GA327", |
| 52 | + "GA400", |
| 53 | + "GA401", |
| 54 | + "GA402", |
| 55 | + "GA403", |
| 56 | + "GA404", |
| 57 | + "GA405", |
| 58 | + "GA406", |
| 59 | + "GA407", |
| 60 | + "GA409", |
| 61 | + "GA410", |
| 62 | + "GA411", |
| 63 | + "GA412", |
| 64 | + "GA413", |
| 65 | + "GA414", |
| 66 | + "GA415", |
| 67 | + "GA416", |
| 68 | + "GA418", |
| 69 | + "GA419", |
| 70 | + "GA420", |
| 71 | + "GA421", |
| 72 | + "GA422", |
| 73 | + "GA423", |
| 74 | + "GA424", |
| 75 | + "GA425", |
| 76 | + "GA426", |
| 77 | + "GA427", |
| 78 | + "GA428", |
| 79 | + "GA429", |
| 80 | + "GA430", |
| 81 | + "GA431", |
| 82 | + "GA432", |
| 83 | + "GA433", |
| 84 | + "GA500", |
| 85 | + "GA501", |
| 86 | + "GA502", |
| 87 | + "GA503", |
| 88 | + "GA600", |
| 89 | + "GA601", |
| 90 | + "GA602", |
| 91 | + } |
| 92 | +) |
| 93 | + |
11 | 94 | # Reusable CEL environment — stateless, safe to share across calls. |
12 | 95 | _CEL_ENV = celpy.Environment() |
13 | 96 |
|
@@ -193,12 +276,33 @@ def _strip_string_literals(expr: str) -> str: |
193 | 276 | _MATCHES_RE = re.compile(r"""matches\(\s*(?:"([^"]+)"|'([^']+)')\s*\)""") |
194 | 277 |
|
195 | 278 | # GA416: sensitivity level in evaluatePreconfiguredWaf/Expr calls. |
196 | | -# The sensitivity key may appear at any position within the options dict, |
197 | | -# so we allow arbitrary content before the "sensitivity" key. |
198 | | -_SENSITIVITY_RE = re.compile( |
199 | | - r"""evaluatePreconfigured(?:Waf|Expr)\(\s*["'][^"']+["']\s*,""" |
200 | | - r"""\s*\{[^}]*?["']sensitivity["']\s*:\s*(\d+)[^}]*\}\s*\)""" |
| 279 | +# Regex finds the call start; _extract_sensitivity() then counts braces |
| 280 | +# to locate the options dict boundary (handles nested dicts and arrays). |
| 281 | +_PRECONFIGURED_CALL_RE = re.compile( |
| 282 | + r"""evaluatePreconfigured(?:Waf|Expr)\(\s*["'][^"']+["']\s*,\s*\{""" |
201 | 283 | ) |
| 284 | +_SENSITIVITY_KV_RE = re.compile(r"""["']sensitivity["']\s*:\s*(\d+)""") |
| 285 | + |
| 286 | + |
| 287 | +def _extract_sensitivity(expr: str, start: int) -> int | None: |
| 288 | + """Extract sensitivity value from options dict starting at *start* (the ``{``). |
| 289 | +
|
| 290 | + Counts braces to find the matching ``}`` so nested dicts/arrays are handled. |
| 291 | + """ |
| 292 | + depth = 0 |
| 293 | + for i in range(start, len(expr)): |
| 294 | + ch = expr[i] |
| 295 | + if ch == "{": |
| 296 | + depth += 1 |
| 297 | + elif ch == "}": |
| 298 | + depth -= 1 |
| 299 | + if depth == 0: |
| 300 | + # Found matching brace — search for sensitivity within |
| 301 | + body = expr[start : i + 1] |
| 302 | + m = _SENSITIVITY_KV_RE.search(body) |
| 303 | + return int(m.group(1)) if m else None |
| 304 | + return None |
| 305 | + |
202 | 306 |
|
203 | 307 | # GA418: header names in request.headers["..."] bracket access |
204 | 308 | _HEADER_BRACKET_RE = re.compile(r"""request\.headers\[\s*["']([^"']+)["']\s*\]""") |
@@ -277,17 +381,39 @@ def _strip_string_literals(expr: str) -> str: |
277 | 381 | } |
278 | 382 | ) |
279 | 383 |
|
280 | | -# RFC 1918 / RFC 4193 / loopback / link-local — flagged as likely mistakes in |
281 | | -# Cloud Armor src_ip_ranges. |
282 | | -_PRIVATE_SUPERNETS = [ |
283 | | - ipaddress.ip_network("10.0.0.0/8"), |
284 | | - ipaddress.ip_network("172.16.0.0/12"), |
285 | | - ipaddress.ip_network("192.168.0.0/16"), |
286 | | - ipaddress.ip_network("127.0.0.0/8"), |
287 | | - ipaddress.ip_network("169.254.0.0/16"), |
288 | | - ipaddress.ip_network("fc00::/7"), |
289 | | - ipaddress.ip_network("::1/128"), |
290 | | - ipaddress.ip_network("fe80::/10"), |
| 384 | +# Reserved/bogon networks (RFC 1918, loopback, link-local, etc.) — flagged as |
| 385 | +# likely mistakes in Cloud Armor src_ip_ranges. |
| 386 | +_PRIVATE_SUPERNETS: list[tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, str]] = [ |
| 387 | + # IPv4 |
| 388 | + (ipaddress.ip_network("10.0.0.0/8"), "RFC 1918 private"), |
| 389 | + (ipaddress.ip_network("172.16.0.0/12"), "RFC 1918 private"), |
| 390 | + (ipaddress.ip_network("192.168.0.0/16"), "RFC 1918 private"), |
| 391 | + (ipaddress.ip_network("127.0.0.0/8"), "loopback"), |
| 392 | + (ipaddress.ip_network("169.254.0.0/16"), "link-local"), |
| 393 | + (ipaddress.ip_network("100.64.0.0/10"), "CGNAT (RFC 6598)"), |
| 394 | + (ipaddress.ip_network("0.0.0.0/8"), "this network"), |
| 395 | + (ipaddress.ip_network("192.0.2.0/24"), "documentation (RFC 5737)"), |
| 396 | + (ipaddress.ip_network("198.51.100.0/24"), "documentation (RFC 5737)"), |
| 397 | + (ipaddress.ip_network("203.0.113.0/24"), "documentation (RFC 5737)"), |
| 398 | + (ipaddress.ip_network("192.0.0.0/24"), "IANA special purpose"), |
| 399 | + (ipaddress.ip_network("192.88.99.0/24"), "6to4 relay anycast"), |
| 400 | + (ipaddress.ip_network("198.18.0.0/15"), "benchmark testing (RFC 2544)"), |
| 401 | + (ipaddress.ip_network("224.0.0.0/4"), "multicast"), |
| 402 | + (ipaddress.ip_network("240.0.0.0/4"), "reserved for future use"), |
| 403 | + # IPv6 |
| 404 | + (ipaddress.ip_network("::/128"), "unspecified"), |
| 405 | + (ipaddress.ip_network("::1/128"), "loopback"), |
| 406 | + (ipaddress.ip_network("::ffff:0:0/96"), "IPv4-mapped"), |
| 407 | + (ipaddress.ip_network("64:ff9b::/96"), "NAT64 (RFC 6052)"), |
| 408 | + (ipaddress.ip_network("100::/64"), "discard (RFC 6666)"), |
| 409 | + (ipaddress.ip_network("2001:db8::/32"), "documentation (RFC 3849)"), |
| 410 | + (ipaddress.ip_network("2001::/23"), "IANA special purpose"), |
| 411 | + (ipaddress.ip_network("2001::/32"), "Teredo"), |
| 412 | + (ipaddress.ip_network("2002::/16"), "6to4"), |
| 413 | + (ipaddress.ip_network("fc00::/7"), "unique local"), |
| 414 | + (ipaddress.ip_network("fe80::/10"), "link-local"), |
| 415 | + (ipaddress.ip_network("ff00::/8"), "multicast"), |
| 416 | + (ipaddress.ip_network("::ffff:0:0:0/96"), "IPv4-translated"), |
291 | 417 | ] |
292 | 418 |
|
293 | 419 |
|
@@ -817,13 +943,13 @@ def _check_cidrs( |
817 | 943 | ) |
818 | 944 |
|
819 | 945 | # GA503: private/reserved range |
820 | | - for private in _PRIVATE_SUPERNETS: |
| 946 | + for private, desc in _PRIVATE_SUPERNETS: |
821 | 947 | if net.version == private.version and net.subnet_of(private): |
822 | 948 | results.append( |
823 | 949 | _result( |
824 | 950 | rule_id="GA503", |
825 | 951 | severity=Severity.WARNING, |
826 | | - message=f"Private/reserved IP range: {cidr}", |
| 952 | + message=f"Private/reserved IP range: {cidr} ({desc})", |
827 | 953 | phase=phase, |
828 | 954 | ref=ref, |
829 | 955 | field="match.config.src_ip_ranges", |
@@ -911,6 +1037,7 @@ def _check_preconfigured( |
911 | 1037 | phase=phase, |
912 | 1038 | ref=ref, |
913 | 1039 | field="match.expr.expression", |
| 1040 | + suggestion=f"Known prefixes: {sorted(_KNOWN_WAF_RULE_SETS)}", |
914 | 1041 | ) |
915 | 1042 | ) |
916 | 1043 |
|
@@ -1145,9 +1272,11 @@ def _check_cel_sensitivity( |
1145 | 1272 | ref: str, |
1146 | 1273 | ) -> None: |
1147 | 1274 | """GA416: preconfigured WAF sensitivity level must be 0-4.""" |
1148 | | - for m in _SENSITIVITY_RE.finditer(expr): |
1149 | | - level = int(m.group(1)) |
1150 | | - if level < 0 or level > 4: |
| 1275 | + for m in _PRECONFIGURED_CALL_RE.finditer(expr): |
| 1276 | + # m.end() points just past the opening '{' of the options dict |
| 1277 | + brace_start = m.end() - 1 |
| 1278 | + level = _extract_sensitivity(expr, brace_start) |
| 1279 | + if level is not None and (level < 0 or level > 4): |
1151 | 1280 | results.append( |
1152 | 1281 | _result( |
1153 | 1282 | rule_id="GA416", |
@@ -1314,13 +1443,13 @@ def _check_cel_iniprange_cidr( |
1314 | 1443 | continue |
1315 | 1444 |
|
1316 | 1445 | # GA320: check for private/reserved ranges |
1317 | | - for private in _PRIVATE_SUPERNETS: |
| 1446 | + for private, desc in _PRIVATE_SUPERNETS: |
1318 | 1447 | if net.version == private.version and net.subnet_of(private): |
1319 | 1448 | results.append( |
1320 | 1449 | _result( |
1321 | 1450 | rule_id="GA320", |
1322 | 1451 | severity=Severity.WARNING, |
1323 | | - message=(f"Private/reserved IP range in inIpRange(): {cidr!r}"), |
| 1452 | + message=f"Private/reserved IP range in inIpRange(): {cidr!r} ({desc})", |
1324 | 1453 | phase=phase, |
1325 | 1454 | ref=ref, |
1326 | 1455 | field="match.expr.expression", |
@@ -1835,12 +1964,11 @@ def _check_exceed_redirect_options( |
1835 | 1964 | _result( |
1836 | 1965 | rule_id="GA411", |
1837 | 1966 | severity=Severity.ERROR, |
1838 | | - message=( |
1839 | | - f"exceed_redirect_options.type must be one of: {sorted(_VALID_REDIRECT_TYPES)}" |
1840 | | - ), |
| 1967 | + message=f"Invalid exceed_redirect_options.type: {ero_type!r}", |
1841 | 1968 | phase=phase, |
1842 | 1969 | ref=ref, |
1843 | 1970 | field="rate_limit_options.exceed_redirect_options.type", |
| 1971 | + suggestion=f"Valid: {sorted(_VALID_REDIRECT_TYPES)}", |
1844 | 1972 | ) |
1845 | 1973 | ) |
1846 | 1974 |
|
|
0 commit comments