cloudarmor-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AIKAWA Shigechika
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudarmor-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Google Cloud Armor WAF log patrol — deny summaries, false-positive checks, preview rules
5
+ Author: AIKAWA Shigechika
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/shigechika/cloudarmor-mcp
8
+ Project-URL: Repository, https://github.com/shigechika/cloudarmor-mcp
9
+ Project-URL: Issues, https://github.com/shigechika/cloudarmor-mcp/issues
10
+ Keywords: cloud-armor,waf,gcp,mcp,model-context-protocol,security,monitoring
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Monitoring
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: google-cloud-logging<4,>=3
24
+ Requires-Dist: mcp<2,>=1.2
25
+ Dynamic: license-file
26
+
27
+ # cloudarmor-mcp
28
+
29
+ MCP server for [Google Cloud Armor](https://cloud.google.com/armor) WAF log patrol — deny summaries per rule, home-region false-positive checks, and preview (dry-run) rule review, straight from Cloud Logging.
30
+
31
+ Built for a daily "is the WAF healthy?" patrol: one `daily_brief` call answers *what did we block, did we block anyone we shouldn't have, and are the preview rules ready to promote*.
32
+
33
+ ## Tools
34
+
35
+ | Tool | Purpose |
36
+ |---|---|
37
+ | `daily_brief` | One-call morning summary: enforced DENYs by rule priority, home-region false-positive lens, preview DENYs |
38
+ | `enforce_denies` | Enforced DENY counts per rule priority |
39
+ | `home_region_denies` | Enforced DENYs whose source IP geolocates to your home region — anything not marked known-normal is a false-positive candidate |
40
+ | `preview_denies` | Preview (dry-run) DENY counts — a quiet preview rule is a promotion candidate |
41
+ | `health_check` | Version, config presence, and a minimal Cloud Logging probe |
42
+
43
+ All tools are read-only. Counts are hard-capped (default 2000 entries per query) and a capped result is reported as `>= N (capped)`, never as an exact total.
44
+
45
+ ## Setup
46
+
47
+ ### 1. Least-privilege service account
48
+
49
+ Create a service account with **`roles/logging.viewer` only** and download a key. Unlike human accounts, service accounts are not subject to organization re-authentication policies, so an unattended patrol never silently expires.
50
+
51
+ ```bash
52
+ gcloud iam service-accounts create waf-log-viewer --project=YOUR_PROJECT
53
+ gcloud projects add-iam-policy-binding YOUR_PROJECT \
54
+ --member=serviceAccount:waf-log-viewer@YOUR_PROJECT.iam.gserviceaccount.com \
55
+ --role=roles/logging.viewer
56
+ gcloud iam service-accounts keys create key.json \
57
+ --iam-account=waf-log-viewer@YOUR_PROJECT.iam.gserviceaccount.com
58
+ ```
59
+
60
+ ### 2. Install
61
+
62
+ ```bash
63
+ pip install cloudarmor-mcp
64
+ # or
65
+ uv tool install cloudarmor-mcp
66
+ ```
67
+
68
+ ### 3. Environment variables
69
+
70
+ | Variable | Required | Meaning |
71
+ |---|---|---|
72
+ | `CLOUDARMOR_PROJECT` | yes | GCP project ID that receives the load-balancer logs |
73
+ | `GOOGLE_APPLICATION_CREDENTIALS` | yes | Path to the service-account key file |
74
+ | `CLOUDARMOR_BACKEND_SERVICES` | no | Comma-separated backend service names to filter (default: all) |
75
+ | `CLOUDARMOR_HOME_REGION` | no | ISO region code treated as home traffic, e.g. `JP`. Enables the false-positive lens |
76
+ | `CLOUDARMOR_RULES_INI` | no | Path to a rules INI (labels + known-normal priorities, see below) |
77
+
78
+ ### 4. Optional rules INI
79
+
80
+ Keep your rule numbering out of prompts and give the reports human-readable labels:
81
+
82
+ ```ini
83
+ [rules]
84
+ 101 = block non-home deep-path crawlers
85
+ 500 = AutoDiscover probe block
86
+ 1002 = OWASP LFI protection
87
+
88
+ [home]
89
+ ; home-region DENYs on these priorities are expected, not false positives
90
+ known_normal_priorities = 500, 600
91
+ ```
92
+
93
+ ### 5. Claude Code
94
+
95
+ ```bash
96
+ claude mcp add cloudarmor -- cloudarmor-mcp
97
+ ```
98
+
99
+ with the environment variables above in the server's env.
100
+
101
+ ## CLI
102
+
103
+ ```bash
104
+ cloudarmor-mcp --version # print version
105
+ cloudarmor-mcp --check # config + API probe (exit 0 when healthy)
106
+ cloudarmor-mcp --brief # print daily_brief to stdout (cron / smoke tests)
107
+ ```
108
+
109
+ ## Reading the report
110
+
111
+ - **Enforced DENY by rule** — your normal blocking volume. Sudden shifts in the mix are worth a look.
112
+ - **Home-region DENY** — requests from your own country/region that were blocked. Legitimate users and legitimate crawlers being denied show up here; scanner traffic that happens to originate locally will too, so the `known_normal_priorities` list keeps expected rules (e.g. an AutoDiscover block) out of the suspicious list.
113
+ - **Preview DENY** — rules in dry-run. A preview rule that stays free of home-region hits over time is a candidate for promotion to enforce.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,91 @@
1
+ # cloudarmor-mcp
2
+
3
+ MCP server for [Google Cloud Armor](https://cloud.google.com/armor) WAF log patrol — deny summaries per rule, home-region false-positive checks, and preview (dry-run) rule review, straight from Cloud Logging.
4
+
5
+ Built for a daily "is the WAF healthy?" patrol: one `daily_brief` call answers *what did we block, did we block anyone we shouldn't have, and are the preview rules ready to promote*.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Purpose |
10
+ |---|---|
11
+ | `daily_brief` | One-call morning summary: enforced DENYs by rule priority, home-region false-positive lens, preview DENYs |
12
+ | `enforce_denies` | Enforced DENY counts per rule priority |
13
+ | `home_region_denies` | Enforced DENYs whose source IP geolocates to your home region — anything not marked known-normal is a false-positive candidate |
14
+ | `preview_denies` | Preview (dry-run) DENY counts — a quiet preview rule is a promotion candidate |
15
+ | `health_check` | Version, config presence, and a minimal Cloud Logging probe |
16
+
17
+ All tools are read-only. Counts are hard-capped (default 2000 entries per query) and a capped result is reported as `>= N (capped)`, never as an exact total.
18
+
19
+ ## Setup
20
+
21
+ ### 1. Least-privilege service account
22
+
23
+ Create a service account with **`roles/logging.viewer` only** and download a key. Unlike human accounts, service accounts are not subject to organization re-authentication policies, so an unattended patrol never silently expires.
24
+
25
+ ```bash
26
+ gcloud iam service-accounts create waf-log-viewer --project=YOUR_PROJECT
27
+ gcloud projects add-iam-policy-binding YOUR_PROJECT \
28
+ --member=serviceAccount:waf-log-viewer@YOUR_PROJECT.iam.gserviceaccount.com \
29
+ --role=roles/logging.viewer
30
+ gcloud iam service-accounts keys create key.json \
31
+ --iam-account=waf-log-viewer@YOUR_PROJECT.iam.gserviceaccount.com
32
+ ```
33
+
34
+ ### 2. Install
35
+
36
+ ```bash
37
+ pip install cloudarmor-mcp
38
+ # or
39
+ uv tool install cloudarmor-mcp
40
+ ```
41
+
42
+ ### 3. Environment variables
43
+
44
+ | Variable | Required | Meaning |
45
+ |---|---|---|
46
+ | `CLOUDARMOR_PROJECT` | yes | GCP project ID that receives the load-balancer logs |
47
+ | `GOOGLE_APPLICATION_CREDENTIALS` | yes | Path to the service-account key file |
48
+ | `CLOUDARMOR_BACKEND_SERVICES` | no | Comma-separated backend service names to filter (default: all) |
49
+ | `CLOUDARMOR_HOME_REGION` | no | ISO region code treated as home traffic, e.g. `JP`. Enables the false-positive lens |
50
+ | `CLOUDARMOR_RULES_INI` | no | Path to a rules INI (labels + known-normal priorities, see below) |
51
+
52
+ ### 4. Optional rules INI
53
+
54
+ Keep your rule numbering out of prompts and give the reports human-readable labels:
55
+
56
+ ```ini
57
+ [rules]
58
+ 101 = block non-home deep-path crawlers
59
+ 500 = AutoDiscover probe block
60
+ 1002 = OWASP LFI protection
61
+
62
+ [home]
63
+ ; home-region DENYs on these priorities are expected, not false positives
64
+ known_normal_priorities = 500, 600
65
+ ```
66
+
67
+ ### 5. Claude Code
68
+
69
+ ```bash
70
+ claude mcp add cloudarmor -- cloudarmor-mcp
71
+ ```
72
+
73
+ with the environment variables above in the server's env.
74
+
75
+ ## CLI
76
+
77
+ ```bash
78
+ cloudarmor-mcp --version # print version
79
+ cloudarmor-mcp --check # config + API probe (exit 0 when healthy)
80
+ cloudarmor-mcp --brief # print daily_brief to stdout (cron / smoke tests)
81
+ ```
82
+
83
+ ## Reading the report
84
+
85
+ - **Enforced DENY by rule** — your normal blocking volume. Sudden shifts in the mix are worth a look.
86
+ - **Home-region DENY** — requests from your own country/region that were blocked. Legitimate users and legitimate crawlers being denied show up here; scanner traffic that happens to originate locally will too, so the `known_normal_priorities` list keeps expected rules (e.g. an AutoDiscover block) out of the suspicious list.
87
+ - **Preview DENY** — rules in dry-run. A preview rule that stays free of home-region hits over time is a candidate for promotion to enforce.
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,3 @@
1
+ """cloudarmor-mcp — Google Cloud Armor WAF log patrol MCP Server."""
2
+
3
+ __version__ = "0.1.0" # x-release-please-version
@@ -0,0 +1,72 @@
1
+ """Entry point for cloudarmor-mcp."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import sys
7
+
8
+ from cloudarmor_mcp import __version__
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(
13
+ description="Google Cloud Armor WAF log patrol MCP Server",
14
+ formatter_class=argparse.RawDescriptionHelpFormatter,
15
+ epilog="""
16
+ Required environment variables:
17
+ CLOUDARMOR_PROJECT GCP project ID that receives the LB logs
18
+ GOOGLE_APPLICATION_CREDENTIALS Service-account key (roles/logging.viewer)
19
+
20
+ Optional environment variables:
21
+ CLOUDARMOR_BACKEND_SERVICES Comma-separated backend service names to filter
22
+ CLOUDARMOR_HOME_REGION ISO region code treated as home (e.g. JP)
23
+ CLOUDARMOR_RULES_INI Rule-priority labels / known-normal priorities
24
+ """,
25
+ )
26
+ parser.add_argument("--version", action="store_true", help="Print version and exit")
27
+ parser.add_argument("--check", action="store_true", help="Verify config and API access, then exit")
28
+ parser.add_argument(
29
+ "--brief",
30
+ action="store_true",
31
+ help="Print the daily_brief to stdout and exit (handy for cron / smoke tests)",
32
+ )
33
+ args = parser.parse_args()
34
+
35
+ if args.version:
36
+ print(f"cloudarmor-mcp {__version__}")
37
+ sys.exit(0)
38
+
39
+ if not os.environ.get("CLOUDARMOR_PROJECT"):
40
+ print("Error: missing environment variable: CLOUDARMOR_PROJECT", file=sys.stderr)
41
+ sys.exit(1)
42
+
43
+ if args.check:
44
+ from cloudarmor_mcp.server import health_check
45
+
46
+ result = health_check()
47
+ print(f"{result['status']} — {result['service']} {result['version']} project={result['project']}")
48
+ if result["probe"] != "ok":
49
+ print(f"probe: {result['probe']}", file=sys.stderr)
50
+ sys.exit(0 if result["status"] == "healthy" else 2)
51
+
52
+ if args.brief:
53
+ from cloudarmor_mcp.server import DEFAULT_SINCE_HOURS, _daily_brief_text
54
+
55
+ text, had_error = _daily_brief_text(DEFAULT_SINCE_HOURS)
56
+ print(text)
57
+ sys.exit(1 if had_error else 0)
58
+
59
+ from cloudarmor_mcp.server import mcp
60
+
61
+ try:
62
+ mcp.run()
63
+ except (KeyboardInterrupt, asyncio.CancelledError):
64
+ # anyio's teardown on SIGINT dumps a 20-80 line traceback. What it
65
+ # raises out of mcp.run() is Python-version-dependent: a bare
66
+ # KeyboardInterrupt on 3.12/3.13, but asyncio.CancelledError on 3.10.
67
+ # Catch both and exit clean like the sibling fleet MCP servers.
68
+ os._exit(0)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -0,0 +1,150 @@
1
+ """Cloud Logging queries for Cloud Armor (http_load_balancer) log entries.
2
+
3
+ The Google client library is imported lazily so unit tests can exercise
4
+ filter building and aggregation without the dependency installed or any
5
+ network access.
6
+ """
7
+
8
+ import os
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+
14
+ class CloudArmorError(Exception):
15
+ """Raised for configuration or Cloud Logging API errors."""
16
+
17
+
18
+ @dataclass
19
+ class Config:
20
+ """Runtime configuration resolved from environment variables."""
21
+
22
+ project: str
23
+ backend_services: list[str] = field(default_factory=list)
24
+ home_region: str | None = None
25
+
26
+ @classmethod
27
+ def from_env(cls) -> "Config":
28
+ project = os.environ.get("CLOUDARMOR_PROJECT", "")
29
+ if not project:
30
+ raise CloudArmorError("CLOUDARMOR_PROJECT is not set")
31
+ backends = [b.strip() for b in os.environ.get("CLOUDARMOR_BACKEND_SERVICES", "").split(",") if b.strip()]
32
+ region = os.environ.get("CLOUDARMOR_HOME_REGION", "").strip() or None
33
+ return cls(project=project, backend_services=backends, home_region=region)
34
+
35
+
36
+ def _quote(value: str) -> str:
37
+ """Quote a value for the Cloud Logging filter language.
38
+
39
+ Values come from the operator's own environment, not from untrusted
40
+ input, but quoting keeps hyphens/dots in service names unambiguous.
41
+ """
42
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
43
+ return f'"{escaped}"'
44
+
45
+
46
+ def start_time(since_hours: float, now: datetime | None = None) -> str:
47
+ """RFC3339 UTC timestamp for the start of the query window."""
48
+ now = now or datetime.now(timezone.utc)
49
+ start = now - timedelta(hours=since_hours)
50
+ return start.strftime("%Y-%m-%dT%H:%M:%SZ")
51
+
52
+
53
+ def build_filter(
54
+ kind: str,
55
+ since_hours: float,
56
+ backend_services: list[str],
57
+ region_code: str | None = None,
58
+ now: datetime | None = None,
59
+ ) -> str:
60
+ """Build a Cloud Logging filter for Cloud Armor DENY entries.
61
+
62
+ kind: "enforced" (enforcedSecurityPolicy.outcome=DENY) or
63
+ "preview" (previewSecurityPolicy.configuredAction=DENY).
64
+ region_code: optionally restrict to requests whose source IP geolocates
65
+ to this ISO region code (e.g. "JP") — the false-positive lens.
66
+ """
67
+ parts = ['resource.type="http_load_balancer"']
68
+ if kind == "enforced":
69
+ parts.append('jsonPayload.enforcedSecurityPolicy.outcome="DENY"')
70
+ elif kind == "preview":
71
+ parts.append('jsonPayload.previewSecurityPolicy.configuredAction="DENY"')
72
+ else:
73
+ raise CloudArmorError(f"unknown filter kind: {kind!r}")
74
+ if region_code:
75
+ parts.append("jsonPayload.securityPolicyRequestData.remoteIpInfo.regionCode=" + _quote(region_code))
76
+ if backend_services:
77
+ if len(backend_services) == 1:
78
+ parts.append(f"resource.labels.backend_service_name={_quote(backend_services[0])}")
79
+ else:
80
+ joined = " OR ".join(_quote(b) for b in backend_services)
81
+ parts.append(f"resource.labels.backend_service_name=({joined})")
82
+ parts.append(f'timestamp >= "{start_time(since_hours, now)}"')
83
+ return " ".join(parts)
84
+
85
+
86
+ @dataclass
87
+ class DenyEntry:
88
+ """The fields of one DENY log entry that the tools report on."""
89
+
90
+ priority: str
91
+ remote_ip: str
92
+ request_url: str
93
+ outcome_kind: str # "enforced" or "preview"
94
+
95
+
96
+ def _normalize_priority(raw) -> str:
97
+ """Rule priorities arrive as JSON numbers (floats) from Cloud Logging.
98
+
99
+ "101.0" would break label lookup and known-normal matching against the
100
+ integer strings operators naturally write in the rules INI, so integral
101
+ floats are folded back to their integer form.
102
+ """
103
+ if isinstance(raw, float) and raw.is_integer():
104
+ return str(int(raw))
105
+ return str(raw)
106
+
107
+
108
+ def _entry_to_deny(entry, kind: str) -> DenyEntry:
109
+ payload = entry.payload if isinstance(entry.payload, dict) else {}
110
+ policy_key = "enforcedSecurityPolicy" if kind == "enforced" else "previewSecurityPolicy"
111
+ priority = _normalize_priority(payload.get(policy_key, {}).get("priority", "?"))
112
+ http = entry.http_request or {}
113
+ return DenyEntry(
114
+ priority=priority,
115
+ remote_ip=str(http.get("remoteIp", "?")),
116
+ request_url=str(http.get("requestUrl", "?")),
117
+ outcome_kind=kind,
118
+ )
119
+
120
+
121
+ class LogClient:
122
+ """Thin wrapper over google-cloud-logging list_entries."""
123
+
124
+ def __init__(self, project: str):
125
+ try:
126
+ from google.cloud import logging as gcl
127
+ except ImportError as e: # pragma: no cover - import guard
128
+ raise CloudArmorError("google-cloud-logging is not installed (pip install cloudarmor-mcp)") from e
129
+ try:
130
+ self._client = gcl.Client(project=project)
131
+ except Exception as e:
132
+ raise CloudArmorError(f"failed to create Cloud Logging client: {e}") from e
133
+ self._descending = gcl.DESCENDING
134
+ self.project = project
135
+
136
+ def deny_entries(self, filter_str: str, kind: str, max_entries: int) -> Iterator[DenyEntry]:
137
+ """Yield up to max_entries DenyEntry rows for the filter, newest first."""
138
+ try:
139
+ it = self._client.list_entries(
140
+ filter_=filter_str,
141
+ order_by=self._descending,
142
+ page_size=min(max_entries, 1000),
143
+ max_results=max_entries,
144
+ )
145
+ for entry in it:
146
+ yield _entry_to_deny(entry, kind)
147
+ except CloudArmorError:
148
+ raise
149
+ except Exception as e:
150
+ raise CloudArmorError(f"Cloud Logging query failed: {e}") from e
@@ -0,0 +1,60 @@
1
+ """Optional site-specific rule annotations for daily_brief.
2
+
3
+ Loaded from an INI file pointed to by ``CLOUDARMOR_RULES_INI``. This keeps
4
+ organization-specific Cloud Armor rule numbering out of the codebase so the
5
+ server stays generic and publishable. When unset or missing, priorities are
6
+ reported without labels and no home-region priority is treated as known-normal.
7
+
8
+ Example::
9
+
10
+ [rules]
11
+ ; label shown next to each rule priority in reports
12
+ 101 = block non-home deep-path crawlers
13
+ 500 = AutoDiscover probe block
14
+ 1002 = OWASP LFI protection
15
+
16
+ [home]
17
+ ; home-region DENYs on these priorities are expected (not false positives)
18
+ known_normal_priorities = 500, 600
19
+ """
20
+
21
+ import configparser
22
+ import os
23
+ from dataclasses import dataclass, field
24
+
25
+
26
+ @dataclass
27
+ class Rules:
28
+ labels: dict[str, str] = field(default_factory=dict)
29
+ known_normal: set[str] = field(default_factory=set)
30
+
31
+ def label(self, priority: str) -> str:
32
+ name = self.labels.get(priority)
33
+ return f" ({name})" if name else ""
34
+
35
+ def is_known_normal(self, priority: str) -> bool:
36
+ return priority in self.known_normal
37
+
38
+
39
+ def load_rules(path: str | None = None) -> Rules:
40
+ """Load rule annotations (env ``CLOUDARMOR_RULES_INI`` by default).
41
+
42
+ Returns empty Rules when no path is configured or the file is absent.
43
+ Raises ``configparser.Error``, ``OSError`` or ``UnicodeDecodeError`` when
44
+ the path exists but cannot be parsed — callers that must not crash on a
45
+ bad config should catch those.
46
+ """
47
+ path = path or os.environ.get("CLOUDARMOR_RULES_INI")
48
+ if not path or not os.path.isfile(path):
49
+ return Rules()
50
+ parser = configparser.ConfigParser()
51
+ with open(path, encoding="utf-8") as f:
52
+ parser.read_file(f)
53
+ labels: dict[str, str] = {}
54
+ if parser.has_section("rules"):
55
+ labels = {k.strip(): v.strip() for k, v in parser.items("rules")}
56
+ known: set[str] = set()
57
+ if parser.has_section("home"):
58
+ raw = parser.get("home", "known_normal_priorities", fallback="")
59
+ known = {p.strip() for p in raw.split(",") if p.strip()}
60
+ return Rules(labels=labels, known_normal=known)
@@ -0,0 +1,240 @@
1
+ """Cloud Armor WAF patrol MCP Server — tools."""
2
+
3
+ import configparser
4
+ import os
5
+ from collections import Counter
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from cloudarmor_mcp.client import CloudArmorError, Config, LogClient, build_filter
10
+ from cloudarmor_mcp.rules import Rules, load_rules
11
+
12
+ mcp = FastMCP("cloudarmor-mcp")
13
+
14
+ # Hard cap on entries fetched per query (CLOUDARMOR_MAX_ENTRIES overrides).
15
+ # When the cap is hit the report says ">= N (capped)" instead of pretending
16
+ # the count is exact — silent truncation must never read as "covered
17
+ # everything".
18
+ DEFAULT_MAX_ENTRIES = 2000
19
+
20
+
21
+ def _max_entries() -> int:
22
+ try:
23
+ return max(1, int(os.environ.get("CLOUDARMOR_MAX_ENTRIES", DEFAULT_MAX_ENTRIES)))
24
+ except ValueError:
25
+ return DEFAULT_MAX_ENTRIES
26
+
27
+
28
+ # How many home-region DENY detail lines to show before folding to a count.
29
+ HOME_DETAIL_LIMIT = 40
30
+
31
+ # Default patrol window (hours). 26 h keeps a daily patrol overlapping
32
+ # yesterday's run instead of leaving a gap when start times drift.
33
+ DEFAULT_SINCE_HOURS = 26.0
34
+
35
+ # Cached client: a stdio server is long-lived and single-user, so build the
36
+ # authenticated client once and reuse its connection pool across calls.
37
+ _CLIENT: LogClient | None = None
38
+
39
+
40
+ def _client() -> LogClient:
41
+ global _CLIENT
42
+ if _CLIENT is None:
43
+ _CLIENT = LogClient(Config.from_env().project)
44
+ return _CLIENT
45
+
46
+
47
+ def reset_client() -> None:
48
+ """Drop the cached client so the next call rebuilds it."""
49
+ global _CLIENT
50
+ _CLIENT = None
51
+
52
+
53
+ def _load_rules_safe() -> tuple[Rules, str | None]:
54
+ try:
55
+ return load_rules(), None
56
+ except (configparser.Error, OSError, UnicodeDecodeError) as e:
57
+ return Rules(), f"rules INI could not be read: {e}"
58
+
59
+
60
+ def _count_fragment(shown: int, cap: int) -> str:
61
+ return f">= {shown} (capped at {cap})" if shown >= cap else str(shown)
62
+
63
+
64
+ def _priority_table(counts: Counter, rules: Rules) -> list[str]:
65
+ lines = []
66
+ for priority, n in counts.most_common():
67
+ lines.append(f" rule {priority}{rules.label(priority)}: {n}")
68
+ return lines
69
+
70
+
71
+ def _collect(kind: str, since_hours: float, region_code: str | None = None) -> tuple[list, int]:
72
+ """Fetch entries for the window; returns (entries, cap) so reports can flag capping."""
73
+ cfg = Config.from_env()
74
+ cap = _max_entries()
75
+ filter_str = build_filter(kind, since_hours, cfg.backend_services, region_code)
76
+ return list(_client().deny_entries(filter_str, kind, cap)), cap
77
+
78
+
79
+ @mcp.tool()
80
+ def health_check() -> dict:
81
+ """Service health: version, config presence and a minimal API probe.
82
+
83
+ Returns a fixed shape (status/service/version + backend fields) so a
84
+ monitoring caller never has to branch on missing keys. status is
85
+ "healthy" when the config loads and a 1-entry probe query succeeds,
86
+ "degraded" when config loads but the probe fails, "error" when the
87
+ config itself is unusable.
88
+ """
89
+ from cloudarmor_mcp import __version__
90
+
91
+ result = {
92
+ "status": "error",
93
+ "service": "cloudarmor-mcp",
94
+ "version": __version__,
95
+ "project": None,
96
+ "backend_services": [],
97
+ "home_region": None,
98
+ "rules_ini": False,
99
+ "probe": None,
100
+ }
101
+ try:
102
+ cfg = Config.from_env()
103
+ except CloudArmorError as e:
104
+ result["probe"] = str(e)
105
+ return result
106
+ result["project"] = cfg.project
107
+ result["backend_services"] = cfg.backend_services
108
+ result["home_region"] = cfg.home_region
109
+ rules, rules_err = _load_rules_safe()
110
+ result["rules_ini"] = bool(rules.labels or rules.known_normal)
111
+ try:
112
+ filter_str = build_filter("enforced", 0.1, cfg.backend_services)
113
+ list(_client().deny_entries(filter_str, "enforced", 1))
114
+ result["status"] = "healthy"
115
+ result["probe"] = "ok"
116
+ except CloudArmorError as e:
117
+ result["status"] = "degraded"
118
+ result["probe"] = str(e)
119
+ if rules_err:
120
+ result["probe"] = f"{result['probe']}; {rules_err}"
121
+ return result
122
+
123
+
124
+ @mcp.tool()
125
+ def enforce_denies(since_hours: float = DEFAULT_SINCE_HOURS) -> str:
126
+ """Enforced DENY counts per Cloud Armor rule priority over the window."""
127
+ rules, rules_err = _load_rules_safe()
128
+ entries, cap = _collect("enforced", since_hours)
129
+ counts = Counter(e.priority for e in entries)
130
+ lines = [f"Enforced DENY, last {since_hours:g}h: {_count_fragment(len(entries), cap)}"]
131
+ lines += _priority_table(counts, rules)
132
+ if rules_err:
133
+ lines.append(f" [warn] {rules_err}")
134
+ return "\n".join(lines)
135
+
136
+
137
+ @mcp.tool()
138
+ def preview_denies(since_hours: float = DEFAULT_SINCE_HOURS) -> str:
139
+ """Preview (dry-run) DENY counts per rule priority over the window.
140
+
141
+ A preview rule that stays free of false positives is a candidate for
142
+ promotion to enforce.
143
+ """
144
+ rules, rules_err = _load_rules_safe()
145
+ entries, cap = _collect("preview", since_hours)
146
+ counts = Counter(e.priority for e in entries)
147
+ lines = [f"Preview DENY, last {since_hours:g}h: {_count_fragment(len(entries), cap)}"]
148
+ lines += _priority_table(counts, rules)
149
+ if rules_err:
150
+ lines.append(f" [warn] {rules_err}")
151
+ return "\n".join(lines)
152
+
153
+
154
+ @mcp.tool()
155
+ def home_region_denies(since_hours: float = DEFAULT_SINCE_HOURS) -> str:
156
+ """Enforced DENYs whose source IP geolocates to the home region.
157
+
158
+ This is the false-positive lens: home-region users being denied on a
159
+ priority that is not marked known-normal deserves review. Requires
160
+ CLOUDARMOR_HOME_REGION.
161
+ """
162
+ cfg = Config.from_env()
163
+ if not cfg.home_region:
164
+ return "CLOUDARMOR_HOME_REGION is not set — home-region check skipped."
165
+ rules, rules_err = _load_rules_safe()
166
+ entries, cap = _collect("enforced", since_hours, region_code=cfg.home_region)
167
+ lines = [
168
+ f"Enforced DENY from region {cfg.home_region}, last {since_hours:g}h: {_count_fragment(len(entries), cap)}"
169
+ ]
170
+ suspicious = [e for e in entries if not rules.is_known_normal(e.priority)]
171
+ normal = len(entries) - len(suspicious)
172
+ if normal:
173
+ lines.append(f" known-normal priorities: {normal} (suppressed)")
174
+ for e in suspicious[:HOME_DETAIL_LIMIT]:
175
+ lines.append(f" rule {e.priority}{rules.label(e.priority)} {e.remote_ip} {e.request_url}")
176
+ if len(suspicious) > HOME_DETAIL_LIMIT:
177
+ lines.append(f" ... and {len(suspicious) - HOME_DETAIL_LIMIT} more")
178
+ if not suspicious:
179
+ lines.append(" no suspicious home-region denies — WAF looks clean")
180
+ if rules_err:
181
+ lines.append(f" [warn] {rules_err}")
182
+ return "\n".join(lines)
183
+
184
+
185
+ def _daily_brief_text(since_hours: float) -> tuple[str, bool]:
186
+ """Build the brief; returns (text, had_error)."""
187
+ cfg = Config.from_env()
188
+ rules, rules_err = _load_rules_safe()
189
+ sections: list[str] = [f"# Cloud Armor brief — project {cfg.project}, last {since_hours:g}h"]
190
+ had_error = False
191
+
192
+ try:
193
+ enforced, cap = _collect("enforced", since_hours)
194
+ counts = Counter(e.priority for e in enforced)
195
+ sections.append(f"## Enforced DENY: {_count_fragment(len(enforced), cap)}")
196
+ sections += _priority_table(counts, rules)
197
+ except CloudArmorError as e:
198
+ sections.append(f"## Enforced DENY: query failed — {e}")
199
+ had_error = True
200
+
201
+ if cfg.home_region:
202
+ try:
203
+ home, cap = _collect("enforced", since_hours, region_code=cfg.home_region)
204
+ suspicious = [e for e in home if not rules.is_known_normal(e.priority)]
205
+ sections.append(
206
+ f"## {cfg.home_region}-sourced DENY (false-positive lens): "
207
+ f"{_count_fragment(len(home), cap)}, suspicious {len(suspicious)}"
208
+ )
209
+ for e in suspicious[:HOME_DETAIL_LIMIT]:
210
+ sections.append(f" rule {e.priority}{rules.label(e.priority)} {e.remote_ip} {e.request_url}")
211
+ if len(suspicious) > HOME_DETAIL_LIMIT:
212
+ sections.append(f" ... and {len(suspicious) - HOME_DETAIL_LIMIT} more")
213
+ if not suspicious:
214
+ sections.append(" none outside known-normal priorities")
215
+ except CloudArmorError as e:
216
+ sections.append(f"## {cfg.home_region}-sourced DENY: query failed — {e}")
217
+ had_error = True
218
+
219
+ try:
220
+ preview, cap = _collect("preview", since_hours)
221
+ counts = Counter(e.priority for e in preview)
222
+ sections.append(f"## Preview DENY: {_count_fragment(len(preview), cap)}")
223
+ sections += _priority_table(counts, rules)
224
+ except CloudArmorError as e:
225
+ sections.append(f"## Preview DENY: query failed — {e}")
226
+ had_error = True
227
+
228
+ if rules_err:
229
+ sections.append(f"[warn] {rules_err}")
230
+ return "\n".join(sections), had_error
231
+
232
+
233
+ @mcp.tool()
234
+ def daily_brief(since_hours: float = DEFAULT_SINCE_HOURS) -> str:
235
+ """Morning patrol summary: enforced DENYs by rule, home-region
236
+
237
+ false-positive check, and preview DENYs, in one call.
238
+ """
239
+ text, _ = _daily_brief_text(since_hours)
240
+ return text
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudarmor-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Google Cloud Armor WAF log patrol — deny summaries, false-positive checks, preview rules
5
+ Author: AIKAWA Shigechika
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/shigechika/cloudarmor-mcp
8
+ Project-URL: Repository, https://github.com/shigechika/cloudarmor-mcp
9
+ Project-URL: Issues, https://github.com/shigechika/cloudarmor-mcp/issues
10
+ Keywords: cloud-armor,waf,gcp,mcp,model-context-protocol,security,monitoring
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Monitoring
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: google-cloud-logging<4,>=3
24
+ Requires-Dist: mcp<2,>=1.2
25
+ Dynamic: license-file
26
+
27
+ # cloudarmor-mcp
28
+
29
+ MCP server for [Google Cloud Armor](https://cloud.google.com/armor) WAF log patrol — deny summaries per rule, home-region false-positive checks, and preview (dry-run) rule review, straight from Cloud Logging.
30
+
31
+ Built for a daily "is the WAF healthy?" patrol: one `daily_brief` call answers *what did we block, did we block anyone we shouldn't have, and are the preview rules ready to promote*.
32
+
33
+ ## Tools
34
+
35
+ | Tool | Purpose |
36
+ |---|---|
37
+ | `daily_brief` | One-call morning summary: enforced DENYs by rule priority, home-region false-positive lens, preview DENYs |
38
+ | `enforce_denies` | Enforced DENY counts per rule priority |
39
+ | `home_region_denies` | Enforced DENYs whose source IP geolocates to your home region — anything not marked known-normal is a false-positive candidate |
40
+ | `preview_denies` | Preview (dry-run) DENY counts — a quiet preview rule is a promotion candidate |
41
+ | `health_check` | Version, config presence, and a minimal Cloud Logging probe |
42
+
43
+ All tools are read-only. Counts are hard-capped (default 2000 entries per query) and a capped result is reported as `>= N (capped)`, never as an exact total.
44
+
45
+ ## Setup
46
+
47
+ ### 1. Least-privilege service account
48
+
49
+ Create a service account with **`roles/logging.viewer` only** and download a key. Unlike human accounts, service accounts are not subject to organization re-authentication policies, so an unattended patrol never silently expires.
50
+
51
+ ```bash
52
+ gcloud iam service-accounts create waf-log-viewer --project=YOUR_PROJECT
53
+ gcloud projects add-iam-policy-binding YOUR_PROJECT \
54
+ --member=serviceAccount:waf-log-viewer@YOUR_PROJECT.iam.gserviceaccount.com \
55
+ --role=roles/logging.viewer
56
+ gcloud iam service-accounts keys create key.json \
57
+ --iam-account=waf-log-viewer@YOUR_PROJECT.iam.gserviceaccount.com
58
+ ```
59
+
60
+ ### 2. Install
61
+
62
+ ```bash
63
+ pip install cloudarmor-mcp
64
+ # or
65
+ uv tool install cloudarmor-mcp
66
+ ```
67
+
68
+ ### 3. Environment variables
69
+
70
+ | Variable | Required | Meaning |
71
+ |---|---|---|
72
+ | `CLOUDARMOR_PROJECT` | yes | GCP project ID that receives the load-balancer logs |
73
+ | `GOOGLE_APPLICATION_CREDENTIALS` | yes | Path to the service-account key file |
74
+ | `CLOUDARMOR_BACKEND_SERVICES` | no | Comma-separated backend service names to filter (default: all) |
75
+ | `CLOUDARMOR_HOME_REGION` | no | ISO region code treated as home traffic, e.g. `JP`. Enables the false-positive lens |
76
+ | `CLOUDARMOR_RULES_INI` | no | Path to a rules INI (labels + known-normal priorities, see below) |
77
+
78
+ ### 4. Optional rules INI
79
+
80
+ Keep your rule numbering out of prompts and give the reports human-readable labels:
81
+
82
+ ```ini
83
+ [rules]
84
+ 101 = block non-home deep-path crawlers
85
+ 500 = AutoDiscover probe block
86
+ 1002 = OWASP LFI protection
87
+
88
+ [home]
89
+ ; home-region DENYs on these priorities are expected, not false positives
90
+ known_normal_priorities = 500, 600
91
+ ```
92
+
93
+ ### 5. Claude Code
94
+
95
+ ```bash
96
+ claude mcp add cloudarmor -- cloudarmor-mcp
97
+ ```
98
+
99
+ with the environment variables above in the server's env.
100
+
101
+ ## CLI
102
+
103
+ ```bash
104
+ cloudarmor-mcp --version # print version
105
+ cloudarmor-mcp --check # config + API probe (exit 0 when healthy)
106
+ cloudarmor-mcp --brief # print daily_brief to stdout (cron / smoke tests)
107
+ ```
108
+
109
+ ## Reading the report
110
+
111
+ - **Enforced DENY by rule** — your normal blocking volume. Sudden shifts in the mix are worth a look.
112
+ - **Home-region DENY** — requests from your own country/region that were blocked. Legitimate users and legitimate crawlers being denied show up here; scanner traffic that happens to originate locally will too, so the `known_normal_priorities` list keeps expected rules (e.g. an AutoDiscover block) out of the suspicious list.
113
+ - **Preview DENY** — rules in dry-run. A preview rule that stays free of home-region hits over time is a candidate for promotion to enforce.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ cloudarmor_mcp/__init__.py
5
+ cloudarmor_mcp/__main__.py
6
+ cloudarmor_mcp/client.py
7
+ cloudarmor_mcp/rules.py
8
+ cloudarmor_mcp/server.py
9
+ cloudarmor_mcp.egg-info/PKG-INFO
10
+ cloudarmor_mcp.egg-info/SOURCES.txt
11
+ cloudarmor_mcp.egg-info/dependency_links.txt
12
+ cloudarmor_mcp.egg-info/entry_points.txt
13
+ cloudarmor_mcp.egg-info/requires.txt
14
+ cloudarmor_mcp.egg-info/top_level.txt
15
+ tests/test_client.py
16
+ tests/test_server.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cloudarmor-mcp = cloudarmor_mcp.__main__:main
@@ -0,0 +1,2 @@
1
+ google-cloud-logging<4,>=3
2
+ mcp<2,>=1.2
@@ -0,0 +1 @@
1
+ cloudarmor_mcp
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cloudarmor-mcp"
7
+ dynamic = ["version"]
8
+ description = "MCP server for Google Cloud Armor WAF log patrol — deny summaries, false-positive checks, preview rules"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ {name = "AIKAWA Shigechika"},
14
+ ]
15
+ keywords = ["cloud-armor", "waf", "gcp", "mcp", "model-context-protocol", "security", "monitoring"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: System Administrators",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: System :: Monitoring",
25
+ "Topic :: Security",
26
+ ]
27
+ dependencies = [
28
+ "google-cloud-logging>=3,<4",
29
+ "mcp>=1.2,<2",
30
+ ]
31
+
32
+ [project.scripts]
33
+ cloudarmor-mcp = "cloudarmor_mcp.__main__:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/shigechika/cloudarmor-mcp"
37
+ Repository = "https://github.com/shigechika/cloudarmor-mcp"
38
+ Issues = "https://github.com/shigechika/cloudarmor-mcp/issues"
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest",
43
+ "pytest-cov",
44
+ "ruff",
45
+ ]
46
+
47
+ [tool.setuptools]
48
+ packages = ["cloudarmor_mcp"]
49
+
50
+ [tool.setuptools.dynamic]
51
+ version = {attr = "cloudarmor_mcp.__version__"}
52
+
53
+ [tool.ruff]
54
+ target-version = "py310"
55
+ line-length = 120
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "W", "UP"]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,95 @@
1
+ """Tests for filter building and configuration."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ import pytest
6
+
7
+ from cloudarmor_mcp.client import CloudArmorError, Config, build_filter, start_time
8
+
9
+ NOW = datetime(2026, 8, 6, 12, 0, 0, tzinfo=timezone.utc)
10
+
11
+
12
+ def test_start_time_subtracts_window():
13
+ assert start_time(24, now=NOW) == "2026-08-05T12:00:00Z"
14
+
15
+
16
+ def test_build_filter_enforced_single_backend():
17
+ f = build_filter("enforced", 26, ["web-backend"], now=NOW)
18
+ assert 'resource.type="http_load_balancer"' in f
19
+ assert 'jsonPayload.enforcedSecurityPolicy.outcome="DENY"' in f
20
+ assert 'resource.labels.backend_service_name="web-backend"' in f
21
+ assert 'timestamp >= "2026-08-05T10:00:00Z"' in f
22
+ assert "previewSecurityPolicy" not in f
23
+
24
+
25
+ def test_build_filter_preview():
26
+ f = build_filter("preview", 1, [], now=NOW)
27
+ assert 'jsonPayload.previewSecurityPolicy.configuredAction="DENY"' in f
28
+ assert "enforcedSecurityPolicy" not in f
29
+ assert "backend_service_name" not in f
30
+
31
+
32
+ def test_build_filter_region_code():
33
+ f = build_filter("enforced", 1, [], region_code="JP", now=NOW)
34
+ assert 'jsonPayload.securityPolicyRequestData.remoteIpInfo.regionCode="JP"' in f
35
+
36
+
37
+ def test_build_filter_multiple_backends_or_group():
38
+ f = build_filter("enforced", 1, ["b-one", "b-two"], now=NOW)
39
+ assert 'resource.labels.backend_service_name=("b-one" OR "b-two")' in f
40
+
41
+
42
+ def test_build_filter_unknown_kind():
43
+ with pytest.raises(CloudArmorError):
44
+ build_filter("nope", 1, [])
45
+
46
+
47
+ def test_config_from_env_requires_project(monkeypatch):
48
+ monkeypatch.delenv("CLOUDARMOR_PROJECT", raising=False)
49
+ with pytest.raises(CloudArmorError):
50
+ Config.from_env()
51
+
52
+
53
+ def test_config_from_env_parses_lists(monkeypatch):
54
+ monkeypatch.setenv("CLOUDARMOR_PROJECT", "proj")
55
+ monkeypatch.setenv("CLOUDARMOR_BACKEND_SERVICES", "a, b ,")
56
+ monkeypatch.setenv("CLOUDARMOR_HOME_REGION", "JP")
57
+ cfg = Config.from_env()
58
+ assert cfg.project == "proj"
59
+ assert cfg.backend_services == ["a", "b"]
60
+ assert cfg.home_region == "JP"
61
+
62
+
63
+ def test_config_from_env_defaults(monkeypatch):
64
+ monkeypatch.setenv("CLOUDARMOR_PROJECT", "proj")
65
+ monkeypatch.delenv("CLOUDARMOR_BACKEND_SERVICES", raising=False)
66
+ monkeypatch.delenv("CLOUDARMOR_HOME_REGION", raising=False)
67
+ cfg = Config.from_env()
68
+ assert cfg.backend_services == []
69
+ assert cfg.home_region is None
70
+
71
+
72
+ class _FakeEntry:
73
+ def __init__(self, payload, http_request=None):
74
+ self.payload = payload
75
+ self.http_request = http_request
76
+
77
+
78
+ def test_entry_to_deny_normalizes_float_priority():
79
+ from cloudarmor_mcp.client import _entry_to_deny
80
+
81
+ entry = _FakeEntry(
82
+ {"enforcedSecurityPolicy": {"priority": 101.0}},
83
+ {"remoteIp": "203.0.113.9", "requestUrl": "https://example.org/"},
84
+ )
85
+ deny = _entry_to_deny(entry, "enforced")
86
+ assert deny.priority == "101"
87
+ assert deny.remote_ip == "203.0.113.9"
88
+
89
+
90
+ def test_entry_to_deny_keeps_int_and_missing():
91
+ from cloudarmor_mcp.client import _entry_to_deny
92
+
93
+ assert _entry_to_deny(_FakeEntry({"enforcedSecurityPolicy": {"priority": 42}}), "enforced").priority == "42"
94
+ assert _entry_to_deny(_FakeEntry({}), "enforced").priority == "?"
95
+ assert _entry_to_deny(_FakeEntry(None, None), "preview").priority == "?"
@@ -0,0 +1,151 @@
1
+ """Tests for tool output shapes with a fake log client (no network)."""
2
+
3
+ import pytest
4
+
5
+ import cloudarmor_mcp.server as server
6
+ from cloudarmor_mcp.client import DenyEntry
7
+
8
+
9
+ class FakeClient:
10
+ """Returns canned entries; records the filters it was asked for."""
11
+
12
+ def __init__(self, entries_by_kind):
13
+ self.entries_by_kind = entries_by_kind
14
+ self.filters = []
15
+
16
+ def deny_entries(self, filter_str, kind, max_entries):
17
+ self.filters.append(filter_str)
18
+ yield from self.entries_by_kind.get(kind, [])[:max_entries]
19
+
20
+
21
+ def _deny(priority, ip="203.0.113.1", url="https://example.org/x", kind="enforced"):
22
+ return DenyEntry(priority=priority, remote_ip=ip, request_url=url, outcome_kind=kind)
23
+
24
+
25
+ @pytest.fixture
26
+ def env(monkeypatch):
27
+ monkeypatch.setenv("CLOUDARMOR_PROJECT", "proj")
28
+ monkeypatch.setenv("CLOUDARMOR_BACKEND_SERVICES", "web-backend")
29
+ monkeypatch.setenv("CLOUDARMOR_HOME_REGION", "JP")
30
+ monkeypatch.delenv("CLOUDARMOR_RULES_INI", raising=False)
31
+ server.reset_client()
32
+ yield
33
+ server.reset_client()
34
+
35
+
36
+ def _install(monkeypatch, fake):
37
+ monkeypatch.setattr(server, "_client", lambda: fake)
38
+
39
+
40
+ def test_enforce_denies_aggregates_by_priority(env, monkeypatch):
41
+ fake = FakeClient({"enforced": [_deny("101"), _deny("101"), _deny("500")]})
42
+ _install(monkeypatch, fake)
43
+ out = server.enforce_denies(since_hours=24)
44
+ assert "Enforced DENY, last 24h: 3" in out
45
+ assert "rule 101: 2" in out
46
+ assert "rule 500: 1" in out
47
+
48
+
49
+ def test_preview_denies_empty(env, monkeypatch):
50
+ fake = FakeClient({})
51
+ _install(monkeypatch, fake)
52
+ out = server.preview_denies(since_hours=24)
53
+ assert "Preview DENY, last 24h: 0" in out
54
+
55
+
56
+ def test_home_region_denies_flags_suspicious(env, monkeypatch, tmp_path):
57
+ rules = tmp_path / "rules.ini"
58
+ rules.write_text("[rules]\n500 = AutoDiscover\n[home]\nknown_normal_priorities = 500\n")
59
+ monkeypatch.setenv("CLOUDARMOR_RULES_INI", str(rules))
60
+ fake = FakeClient({"enforced": [_deny("500"), _deny("500"), _deny("1002", ip="198.51.100.7")]})
61
+ _install(monkeypatch, fake)
62
+ out = server.home_region_denies(since_hours=24)
63
+ assert "known-normal priorities: 2 (suppressed)" in out
64
+ assert "rule 1002" in out
65
+ assert "198.51.100.7" in out
66
+ # region filter actually reached the query
67
+ assert any('regionCode="JP"' in f for f in fake.filters)
68
+
69
+
70
+ def test_home_region_denies_without_region(env, monkeypatch):
71
+ monkeypatch.delenv("CLOUDARMOR_HOME_REGION")
72
+ fake = FakeClient({})
73
+ _install(monkeypatch, fake)
74
+ out = server.home_region_denies(since_hours=24)
75
+ assert "not set" in out
76
+
77
+
78
+ def test_home_region_denies_all_normal(env, monkeypatch, tmp_path):
79
+ rules = tmp_path / "rules.ini"
80
+ rules.write_text("[home]\nknown_normal_priorities = 500\n")
81
+ monkeypatch.setenv("CLOUDARMOR_RULES_INI", str(rules))
82
+ fake = FakeClient({"enforced": [_deny("500")]})
83
+ _install(monkeypatch, fake)
84
+ out = server.home_region_denies(since_hours=24)
85
+ assert "no suspicious home-region denies" in out
86
+
87
+
88
+ def test_daily_brief_contains_all_sections(env, monkeypatch):
89
+ fake = FakeClient(
90
+ {
91
+ "enforced": [_deny("101"), _deny("500")],
92
+ "preview": [_deny("1010", kind="preview")],
93
+ }
94
+ )
95
+ _install(monkeypatch, fake)
96
+ out = server.daily_brief(since_hours=26)
97
+ assert "# Cloud Armor brief — project proj, last 26h" in out
98
+ assert "## Enforced DENY: 2" in out
99
+ assert "## JP-sourced DENY" in out
100
+ assert "## Preview DENY: 1" in out
101
+
102
+
103
+ def test_daily_brief_rule_labels(env, monkeypatch, tmp_path):
104
+ rules = tmp_path / "rules.ini"
105
+ rules.write_text("[rules]\n101 = deep-path crawler block\n")
106
+ monkeypatch.setenv("CLOUDARMOR_RULES_INI", str(rules))
107
+ fake = FakeClient({"enforced": [_deny("101")]})
108
+ _install(monkeypatch, fake)
109
+ out = server.daily_brief(since_hours=26)
110
+ assert "rule 101 (deep-path crawler block): 1" in out
111
+
112
+
113
+ def test_capped_count_is_flagged(env, monkeypatch):
114
+ monkeypatch.setenv("CLOUDARMOR_MAX_ENTRIES", "5")
115
+ entries = [_deny("101") for _ in range(10)]
116
+ fake = FakeClient({"enforced": entries})
117
+ _install(monkeypatch, fake)
118
+ out = server.enforce_denies(since_hours=24)
119
+ assert ">= 5 (capped at 5)" in out
120
+
121
+
122
+ def test_max_entries_env_invalid_falls_back(monkeypatch):
123
+ monkeypatch.setenv("CLOUDARMOR_MAX_ENTRIES", "not-a-number")
124
+ assert server._max_entries() == server.DEFAULT_MAX_ENTRIES
125
+
126
+
127
+ def test_health_check_shape_without_project(monkeypatch):
128
+ monkeypatch.delenv("CLOUDARMOR_PROJECT", raising=False)
129
+ server.reset_client()
130
+ result = server.health_check()
131
+ assert result["status"] == "error"
132
+ assert result["service"] == "cloudarmor-mcp"
133
+ assert set(result) == {
134
+ "status",
135
+ "service",
136
+ "version",
137
+ "project",
138
+ "backend_services",
139
+ "home_region",
140
+ "rules_ini",
141
+ "probe",
142
+ }
143
+
144
+
145
+ def test_health_check_healthy_with_fake(env, monkeypatch):
146
+ fake = FakeClient({"enforced": []})
147
+ _install(monkeypatch, fake)
148
+ result = server.health_check()
149
+ assert result["status"] == "healthy"
150
+ assert result["probe"] == "ok"
151
+ assert result["project"] == "proj"