Skip to content

Commit 927f2a8

Browse files
barnumbirrclaude
andcommitted
v0.9.1: GA416 regex fix, reserved IP expansion, suggestion= improvements
- GA416: sensitivity regex now handles nested option dicts via brace-counting - GA303/GA411: valid options moved to suggestion field - Reserved IP list expanded from 8 to 28 networks - Explicit RULE_IDS per validator module for dead-rule detection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0138ca7 commit 927f2a8

5 files changed

Lines changed: 250 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.9.1] - 2026-04-13
9+
10+
### Fixed
11+
- GA416: Sensitivity regex now handles nested option dicts (e.g.
12+
`opt_out_rule_ids` arrays) via brace-counting instead of `[^}]*?`.
13+
14+
### Changed
15+
- GA303, GA411: Valid options moved to `suggestion` field.
16+
- Reserved IP list expanded from 8 to 28 networks (adds CGNAT, documentation,
17+
benchmark, multicast, IPv6 ranges).
18+
- Explicit `RULE_IDS` per validator module for dead-rule detection.
19+
820
## [0.9.0] - 2026-04-10
921

1022
### Added

octorules_google/linter/_plugin.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from octorules.phases import PHASE_BY_NAME
77

88
from octorules_google import GCLOUD_PHASE_NAMES
9-
from octorules_google.linter._rules import GA_RULE_METAS
9+
from octorules_google.validate import RULE_IDS as _validate_ids
1010
from octorules_google.validate import (
1111
validate_regex_rule_count,
1212
validate_rule_count,
@@ -16,7 +16,14 @@
1616
# Re-export for backward compatibility
1717
_GCLOUD_PHASE_NAMES = GCLOUD_PHASE_NAMES
1818

19-
GA_RULE_IDS: frozenset[str] = frozenset(r.rule_id for r in GA_RULE_METAS)
19+
# Rule IDs emitted by cross-phase checks in this module.
20+
_PLUGIN_RULE_IDS: frozenset[str] = frozenset(
21+
{
22+
"GA006",
23+
}
24+
)
25+
26+
GA_RULE_IDS: frozenset[str] = _validate_ids | _PLUGIN_RULE_IDS
2027

2128

2229
def google_lint(rules_data: dict[str, Any], ctx: LintContext) -> None:

octorules_google/validate.py

Lines changed: 154 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,89 @@
88
import celpy
99
from octorules.linter.engine import LintResult, Severity, is_always_false, is_always_true
1010

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+
1194
# Reusable CEL environment — stateless, safe to share across calls.
1295
_CEL_ENV = celpy.Environment()
1396

@@ -193,12 +276,33 @@ def _strip_string_literals(expr: str) -> str:
193276
_MATCHES_RE = re.compile(r"""matches\(\s*(?:"([^"]+)"|'([^']+)')\s*\)""")
194277

195278
# 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*\{"""
201283
)
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+
202306

203307
# GA418: header names in request.headers["..."] bracket access
204308
_HEADER_BRACKET_RE = re.compile(r"""request\.headers\[\s*["']([^"']+)["']\s*\]""")
@@ -277,17 +381,39 @@ def _strip_string_literals(expr: str) -> str:
277381
}
278382
)
279383

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"),
291417
]
292418

293419

@@ -817,13 +943,13 @@ def _check_cidrs(
817943
)
818944

819945
# GA503: private/reserved range
820-
for private in _PRIVATE_SUPERNETS:
946+
for private, desc in _PRIVATE_SUPERNETS:
821947
if net.version == private.version and net.subnet_of(private):
822948
results.append(
823949
_result(
824950
rule_id="GA503",
825951
severity=Severity.WARNING,
826-
message=f"Private/reserved IP range: {cidr}",
952+
message=f"Private/reserved IP range: {cidr} ({desc})",
827953
phase=phase,
828954
ref=ref,
829955
field="match.config.src_ip_ranges",
@@ -911,6 +1037,7 @@ def _check_preconfigured(
9111037
phase=phase,
9121038
ref=ref,
9131039
field="match.expr.expression",
1040+
suggestion=f"Known prefixes: {sorted(_KNOWN_WAF_RULE_SETS)}",
9141041
)
9151042
)
9161043

@@ -1145,9 +1272,11 @@ def _check_cel_sensitivity(
11451272
ref: str,
11461273
) -> None:
11471274
"""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):
11511280
results.append(
11521281
_result(
11531282
rule_id="GA416",
@@ -1314,13 +1443,13 @@ def _check_cel_iniprange_cidr(
13141443
continue
13151444

13161445
# GA320: check for private/reserved ranges
1317-
for private in _PRIVATE_SUPERNETS:
1446+
for private, desc in _PRIVATE_SUPERNETS:
13181447
if net.version == private.version and net.subnet_of(private):
13191448
results.append(
13201449
_result(
13211450
rule_id="GA320",
13221451
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})",
13241453
phase=phase,
13251454
ref=ref,
13261455
field="match.expr.expression",
@@ -1835,12 +1964,11 @@ def _check_exceed_redirect_options(
18351964
_result(
18361965
rule_id="GA411",
18371966
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}",
18411968
phase=phase,
18421969
ref=ref,
18431970
field="rate_limit_options.exceed_redirect_options.type",
1971+
suggestion=f"Valid: {sorted(_VALID_REDIRECT_TYPES)}",
18441972
)
18451973
)
18461974

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "octorules-google"
7-
version = "0.9.0"
7+
version = "0.9.1"
88
description = "Google Cloud Armor provider for octorules"
99
license = "Apache-2.0"
1010
requires-python = ">=3.10"

tests/test_validate.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,52 @@ def test_ga503_ipv6_ula(self):
505505
match = {"config": {"src_ip_ranges": ["fd00::/8"]}, "versioned_expr": "SRC_IPS_V1"}
506506
assert "GA503" in _ids(validate_rules([_rule(match=match)]))
507507

508+
def test_ga503_cgnat(self):
509+
"""CGNAT range (100.64.0.0/10) should be flagged as reserved."""
510+
match = {"config": {"src_ip_ranges": ["100.64.1.0/24"]}, "versioned_expr": "SRC_IPS_V1"}
511+
results = validate_rules([_rule(match=match)])
512+
assert "GA503" in _ids(results)
513+
ga503 = [r for r in results if r.rule_id == "GA503"]
514+
assert "CGNAT" in ga503[0].message
515+
516+
def test_ga503_documentation_rfc5737(self):
517+
"""RFC 5737 documentation addresses should be flagged."""
518+
match = {
519+
"config": {"src_ip_ranges": ["192.0.2.0/24"]},
520+
"versioned_expr": "SRC_IPS_V1",
521+
}
522+
results = validate_rules([_rule(match=match)])
523+
assert "GA503" in _ids(results)
524+
ga503 = [r for r in results if r.rule_id == "GA503"]
525+
assert "documentation" in ga503[0].message
526+
527+
def test_ga503_benchmark_testing(self):
528+
"""RFC 2544 benchmark testing addresses should be flagged."""
529+
match = {
530+
"config": {"src_ip_ranges": ["198.18.0.0/15"]},
531+
"versioned_expr": "SRC_IPS_V1",
532+
}
533+
assert "GA503" in _ids(validate_rules([_rule(match=match)]))
534+
535+
def test_ga503_ipv6_documentation(self):
536+
"""IPv6 documentation prefix (2001:db8::/32) should be flagged."""
537+
match = {
538+
"config": {"src_ip_ranges": ["2001:db8::/32"]},
539+
"versioned_expr": "SRC_IPS_V1",
540+
}
541+
results = validate_rules([_rule(match=match)])
542+
assert "GA503" in _ids(results)
543+
ga503 = [r for r in results if r.rule_id == "GA503"]
544+
assert "documentation" in ga503[0].message
545+
546+
def test_ga503_multicast(self):
547+
"""Multicast range (224.0.0.0/4) should be flagged."""
548+
match = {
549+
"config": {"src_ip_ranges": ["224.0.0.0/4"]},
550+
"versioned_expr": "SRC_IPS_V1",
551+
}
552+
assert "GA503" in _ids(validate_rules([_rule(match=match)]))
553+
508554
# --- GA307: CIDR host bits normalization warning ---
509555

510556
def test_ga307_host_bits_set(self):
@@ -2209,6 +2255,22 @@ def test_ga416_preconfigured_expr_variant(self):
22092255
match = {"expr": {"expression": expr}}
22102256
assert "GA416" in _ids(validate_rules([_rule(match=match)]))
22112257

2258+
def test_ga416_sensitivity_with_nested_dict(self):
2259+
expr = (
2260+
"evaluatePreconfiguredWaf('sqli-v33-stable',"
2261+
" {'sensitivity': 5, 'opt_out_rule_ids': ['rule1']})"
2262+
)
2263+
match = {"expr": {"expression": expr}}
2264+
assert "GA416" in _ids(validate_rules([_rule(match=match)]))
2265+
2266+
def test_ga416_sensitivity_with_nested_dict_valid(self):
2267+
expr = (
2268+
"evaluatePreconfiguredWaf('sqli-v33-stable',"
2269+
" {'sensitivity': 3, 'opt_out_rule_ids': ['rule1']})"
2270+
)
2271+
match = {"expr": {"expression": expr}}
2272+
assert "GA416" not in _ids(validate_rules([_rule(match=match)]))
2273+
22122274

22132275
# ---------------------------------------------------------------------------
22142276
# GA418 Invalid header name in CEL bracket access
@@ -2447,6 +2509,18 @@ def test_ga320_ipv6_ula(self):
24472509
results = validate_rules([_rule(match=match)])
24482510
assert "GA320" in _ids(results)
24492511

2512+
def test_ga320_cgnat(self):
2513+
"""CGNAT range should be flagged in inIpRange expressions."""
2514+
match = {"expr": {"expression": "inIpRange(origin.ip, '100.64.1.1/32')"}}
2515+
results = validate_rules([_rule(match=match)])
2516+
assert "GA320" in _ids(results)
2517+
2518+
def test_ga320_documentation_rfc5737(self):
2519+
"""RFC 5737 documentation range should be flagged."""
2520+
match = {"expr": {"expression": "inIpRange(origin.ip, '198.51.100.0/24')"}}
2521+
results = validate_rules([_rule(match=match)])
2522+
assert "GA320" in _ids(results)
2523+
24502524
def test_ga317_double_quoted(self):
24512525
match = {"expr": {"expression": 'inIpRange(origin.ip, "8.8.8.0/24")'}}
24522526
assert "GA317" not in _ids(validate_rules([_rule(match=match)]))

0 commit comments

Comments
 (0)