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.
- cloudarmor_mcp-0.1.0/LICENSE +21 -0
- cloudarmor_mcp-0.1.0/PKG-INFO +117 -0
- cloudarmor_mcp-0.1.0/README.md +91 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp/__init__.py +3 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp/__main__.py +72 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp/client.py +150 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp/rules.py +60 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp/server.py +240 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp.egg-info/PKG-INFO +117 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp.egg-info/SOURCES.txt +16 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp.egg-info/dependency_links.txt +1 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp.egg-info/entry_points.txt +2 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp.egg-info/requires.txt +2 -0
- cloudarmor_mcp-0.1.0/cloudarmor_mcp.egg-info/top_level.txt +1 -0
- cloudarmor_mcp-0.1.0/pyproject.toml +61 -0
- cloudarmor_mcp-0.1.0/setup.cfg +4 -0
- cloudarmor_mcp-0.1.0/tests/test_client.py +95 -0
- cloudarmor_mcp-0.1.0/tests/test_server.py +151 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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"
|