Skip to content

Commit 5be559f

Browse files
barnumbirrclaude
andcommitted
v0.9.2: migrate to octorules.reserved_ips + @idempotent_registration
Drops the local _PRIVATE_SUPERNETS list in favor of octorules.reserved_ips from core v0.26.0. GA503 (src_ip_ranges) and GA320 (inIpRange() CEL) now delegate to is_reserved() — same strict-containment semantics, same messages, same rule IDs. Replaces the flag+lock idempotent-registration pattern in linter/__init__.py and _policy_settings.py with @idempotent_registration from core. Minor safety upgrade: the registration body now runs inside the lock rather than only the flag-flip. Adds tests/test_cli_lint.py exercising the octorules lint CLI with a Google provider config. Bumps octorules dep >=0.24.0 -> >=0.26.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 927f2a8 commit 5be559f

6 files changed

Lines changed: 184 additions & 106 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ 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.2] - 2026-04-18
9+
10+
### Changed
11+
- Minimum ``octorules`` dependency: ``>=0.26.0`` (was ``>=0.24.0``).
12+
813
## [0.9.1] - 2026-04-13
914

1015
### Fixed

octorules_google/_policy_settings.py

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
"""
1111

1212
import logging
13-
import threading
1413
from dataclasses import dataclass, field
1514

15+
from octorules.registration import idempotent_registration
16+
1617
log = logging.getLogger(__name__)
1718

1819
_EXT_KEY = "gcloud_armor_policy_settings"
@@ -402,28 +403,19 @@ def format_report(self, plans: list, zone_has_drift: bool, phases_data: list[dic
402403
# ---------------------------------------------------------------------------
403404
# Registration
404405
# ---------------------------------------------------------------------------
405-
_registered = False
406-
_register_lock = threading.Lock()
407-
408-
406+
@idempotent_registration
409407
def register_policy_settings() -> None:
410408
"""Register all policy settings hooks with the core extension system."""
411-
global _registered
412-
with _register_lock:
413-
if _registered:
414-
return
415-
416-
from octorules.extensions import (
417-
register_apply_extension,
418-
register_dump_extension,
419-
register_format_extension,
420-
register_plan_zone_hook,
421-
register_validate_extension,
422-
)
423-
424-
register_plan_zone_hook(_prefetch_policy_settings, _finalize_policy_settings)
425-
register_apply_extension(_EXT_KEY, _apply_policy_settings)
426-
register_format_extension(_EXT_KEY, PolicySettingsFormatter())
427-
register_validate_extension(_validate_policy_settings)
428-
register_dump_extension(_dump_policy_settings)
429-
_registered = True
409+
from octorules.extensions import (
410+
register_apply_extension,
411+
register_dump_extension,
412+
register_format_extension,
413+
register_plan_zone_hook,
414+
register_validate_extension,
415+
)
416+
417+
register_plan_zone_hook(_prefetch_policy_settings, _finalize_policy_settings)
418+
register_apply_extension(_EXT_KEY, _apply_policy_settings)
419+
register_format_extension(_EXT_KEY, PolicySettingsFormatter())
420+
register_validate_extension(_validate_policy_settings)
421+
register_dump_extension(_dump_policy_settings)
Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,16 @@
11
"""Google Cloud Armor linter — registers all GCloud-specific lint rules and plugins."""
22

3-
import threading
4-
5-
_registered = False
6-
_register_lock = threading.Lock()
3+
from octorules.registration import idempotent_registration
74

85

6+
@idempotent_registration
97
def register_google_linter() -> None:
10-
"""Register the Google Cloud Armor lint plugin and rule definitions.
11-
12-
Safe to call multiple times — subsequent calls are no-ops.
13-
"""
14-
global _registered
15-
with _register_lock:
16-
if _registered:
17-
return
18-
19-
from octorules.linter.plugin import LintPlugin, register_linter
20-
from octorules.linter.rules.registry import register_rules
21-
22-
from octorules_google.linter._plugin import GA_RULE_IDS, google_lint
23-
from octorules_google.linter._rules import GA_RULE_METAS
8+
"""Register the Google Cloud Armor lint plugin and rule definitions."""
9+
from octorules.linter.plugin import LintPlugin, register_linter
10+
from octorules.linter.rules.registry import register_rules
2411

25-
register_linter(LintPlugin(name="google", lint_fn=google_lint, rule_ids=GA_RULE_IDS))
26-
register_rules(GA_RULE_METAS)
12+
from octorules_google.linter._plugin import GA_RULE_IDS, google_lint
13+
from octorules_google.linter._rules import GA_RULE_METAS
2714

28-
_registered = True
15+
register_linter(LintPlugin(name="google", lint_fn=google_lint, rule_ids=GA_RULE_IDS))
16+
register_rules(GA_RULE_METAS)

octorules_google/validate.py

Lines changed: 26 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import celpy
99
from octorules.linter.engine import LintResult, Severity, is_always_false, is_always_true
10+
from octorules.reserved_ips import is_reserved
1011

1112
# Rule IDs emitted by validate_rules() — kept in sync with _rules.py by
1213
# test_plugin_rule_ids_match_metas.
@@ -381,40 +382,8 @@ def _extract_sensitivity(expr: str, start: int) -> int | None:
381382
}
382383
)
383384

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"),
417-
]
385+
# Reserved/bogon network detection is provided by octorules.reserved_ips
386+
# (single source of truth across providers; see core v0.26.0).
418387

419388

420389
def validate_rules(rules: list[dict], *, phase: str = "") -> list[LintResult]:
@@ -943,19 +912,18 @@ def _check_cidrs(
943912
)
944913

945914
# GA503: private/reserved range
946-
for private, desc in _PRIVATE_SUPERNETS:
947-
if net.version == private.version and net.subnet_of(private):
948-
results.append(
949-
_result(
950-
rule_id="GA503",
951-
severity=Severity.WARNING,
952-
message=f"Private/reserved IP range: {cidr} ({desc})",
953-
phase=phase,
954-
ref=ref,
955-
field="match.config.src_ip_ranges",
956-
)
915+
desc = is_reserved(cidr)
916+
if desc is not None:
917+
results.append(
918+
_result(
919+
rule_id="GA503",
920+
severity=Severity.WARNING,
921+
message=f"Private/reserved IP range: {cidr} ({desc})",
922+
phase=phase,
923+
ref=ref,
924+
field="match.config.src_ip_ranges",
957925
)
958-
break
926+
)
959927

960928
# GA305: overlapping CIDRs
961929
for i, (cidr_a, net_a) in enumerate(networks):
@@ -1428,7 +1396,7 @@ def _check_cel_iniprange_cidr(
14281396
for m in _IN_IP_RANGE_RE.finditer(expr):
14291397
cidr = m.group(1)
14301398
try:
1431-
net = ipaddress.ip_network(cidr, strict=False)
1399+
ipaddress.ip_network(cidr, strict=False)
14321400
except ValueError as exc:
14331401
results.append(
14341402
_result(
@@ -1443,19 +1411,18 @@ def _check_cel_iniprange_cidr(
14431411
continue
14441412

14451413
# GA320: check for private/reserved ranges
1446-
for private, desc in _PRIVATE_SUPERNETS:
1447-
if net.version == private.version and net.subnet_of(private):
1448-
results.append(
1449-
_result(
1450-
rule_id="GA320",
1451-
severity=Severity.WARNING,
1452-
message=f"Private/reserved IP range in inIpRange(): {cidr!r} ({desc})",
1453-
phase=phase,
1454-
ref=ref,
1455-
field="match.expr.expression",
1456-
)
1414+
desc = is_reserved(cidr)
1415+
if desc is not None:
1416+
results.append(
1417+
_result(
1418+
rule_id="GA320",
1419+
severity=Severity.WARNING,
1420+
message=f"Private/reserved IP range in inIpRange(): {cidr!r} ({desc})",
1421+
phase=phase,
1422+
ref=ref,
1423+
field="match.expr.expression",
14571424
)
1458-
break
1425+
)
14591426

14601427

14611428
def _check_cel_type_mismatch(

pyproject.toml

Lines changed: 2 additions & 2 deletions
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.1"
7+
version = "0.9.2"
88
description = "Google Cloud Armor provider for octorules"
99
license = "Apache-2.0"
1010
requires-python = ">=3.10"
@@ -28,7 +28,7 @@ classifiers = [
2828
"Topic :: System :: Systems Administration",
2929
]
3030
dependencies = [
31-
"octorules>=0.24.0",
31+
"octorules>=0.26.0",
3232
"google-cloud-compute>=1.10,<2.0",
3333
"cel-python>=0.1",
3434
]

tests/test_cli_lint.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""End-to-end tests for the 'octorules lint' CLI command with the Google provider."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
from octorules.cli import build_parser, cmd_lint, main
7+
from octorules.config import Config
8+
9+
# Importing the provider module triggers register_google_linter() at
10+
# module load time, which is what cmd_lint depends on.
11+
import octorules_google # noqa: F401
12+
13+
14+
@pytest.fixture
15+
def lint_config(tmp_path):
16+
"""Minimal config + rules files exercising Google-specific lint paths."""
17+
rules_dir = tmp_path / "rules"
18+
rules_dir.mkdir()
19+
20+
# Clean rules — public IPs only.
21+
(rules_dir / "clean-policy.yaml").write_text(
22+
"gcloud_armor_custom_rules:\n"
23+
" - ref: '1000'\n"
24+
" description: Block public bad IP\n"
25+
" action: deny(403)\n"
26+
" match:\n"
27+
" versioned_expr: SRC_IPS_V1\n"
28+
" config:\n"
29+
" src_ip_ranges:\n"
30+
" - 1.2.3.0/24\n"
31+
)
32+
33+
# Rules with multiple GA violations.
34+
(rules_dir / "bad-policy.yaml").write_text(
35+
"gcloud_armor_custom_rules:\n"
36+
# GA503: reserved IP in src_ip_ranges.
37+
" - ref: '1000'\n"
38+
" description: Accidentally block RFC 1918\n"
39+
" action: deny(403)\n"
40+
" match:\n"
41+
" versioned_expr: SRC_IPS_V1\n"
42+
" config:\n"
43+
" src_ip_ranges:\n"
44+
" - 10.0.0.0/8\n"
45+
# GA306: catch-all /0.
46+
" - ref: '1001'\n"
47+
" description: Catch-all\n"
48+
" action: deny(403)\n"
49+
" match:\n"
50+
" versioned_expr: SRC_IPS_V1\n"
51+
" config:\n"
52+
" src_ip_ranges:\n"
53+
" - 0.0.0.0/0\n"
54+
)
55+
56+
config_file = tmp_path / "config.yaml"
57+
config_file.write_text(
58+
"providers:\n"
59+
" google:\n"
60+
" project: test-project\n"
61+
" rules:\n"
62+
" directory: ./rules\n"
63+
"zones:\n"
64+
" clean-policy:\n"
65+
" sources:\n"
66+
" - rules\n"
67+
" bad-policy:\n"
68+
" sources:\n"
69+
" - rules\n"
70+
)
71+
return Config.from_file(config_file)
72+
73+
74+
class TestBuildParser:
75+
def test_lint_subcommand_exists(self):
76+
parser = build_parser()
77+
args = parser.parse_args(["lint"])
78+
assert args.command == "lint"
79+
80+
def test_lint_rule_filter_accepts_ga_codes(self):
81+
parser = build_parser()
82+
args = parser.parse_args(["lint", "--rule", "GA503", "--rule", "GA306"])
83+
assert args.lint_rules == ["GA503", "GA306"]
84+
85+
86+
class TestCmdLint:
87+
def test_clean_rules_exit_0(self, lint_config):
88+
rc = cmd_lint(lint_config, ["clean-policy"])
89+
assert rc == 0
90+
91+
def test_bad_rules_surface_findings(self, lint_config, capsys):
92+
cmd_lint(lint_config, ["bad-policy"])
93+
captured = capsys.readouterr()
94+
assert "GA503" in captured.out
95+
assert "GA306" in captured.out
96+
97+
def test_json_format(self, lint_config, capsys):
98+
cmd_lint(lint_config, ["bad-policy"], lint_format="json")
99+
captured = capsys.readouterr()
100+
assert '"rule_id"' in captured.out
101+
assert "GA503" in captured.out
102+
103+
def test_sarif_format(self, lint_config, capsys):
104+
cmd_lint(lint_config, ["bad-policy"], lint_format="sarif")
105+
captured = capsys.readouterr()
106+
assert '"version": "2.1.0"' in captured.out
107+
108+
def test_rule_filter_scopes_output(self, lint_config, capsys):
109+
cmd_lint(lint_config, ["bad-policy"], lint_rules=["GA503"])
110+
captured = capsys.readouterr()
111+
assert "GA503" in captured.out
112+
assert "GA306" not in captured.out
113+
114+
def test_output_file(self, lint_config, tmp_path):
115+
out_file = str(tmp_path / "lint-report.txt")
116+
cmd_lint(lint_config, ["bad-policy"], output_file=out_file)
117+
assert Path(out_file).exists()
118+
assert "GA" in Path(out_file).read_text()
119+
120+
121+
class TestMainLintCommand:
122+
def test_main_lint_exits_zero_on_clean(self, lint_config, tmp_path):
123+
config_file = tmp_path / "config.yaml"
124+
with pytest.raises(SystemExit) as exc_info:
125+
main(["--config", str(config_file), "lint", "--zone", "clean-policy"])
126+
assert exc_info.value.code == 0

0 commit comments

Comments
 (0)