correctover-compliance-check 1.0.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.
- correctover_compliance_check-1.0.0/.gitignore +3 -0
- correctover_compliance_check-1.0.0/PKG-INFO +18 -0
- correctover_compliance_check-1.0.0/README.md +17 -0
- correctover_compliance_check-1.0.0/correctover_agent/__init__.py +8 -0
- correctover_compliance_check-1.0.0/correctover_agent/checker.py +429 -0
- correctover_compliance_check-1.0.0/correctover_agent/cli.py +189 -0
- correctover_compliance_check-1.0.0/pyproject.toml +32 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: correctover-compliance-check
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP OAuth 2.1 & CCS v1.0 Compliance Checker — enforce the 2026-07-28 mandate
|
|
5
|
+
Project-URL: Homepage, https://correctover.com
|
|
6
|
+
Project-URL: Repository, https://github.com/Correctover/compliance-check
|
|
7
|
+
Project-URL: Issues, https://github.com/Correctover/compliance-check/issues
|
|
8
|
+
Author-email: Correctover <wangguigui@correctover.com>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: audit,ccs,compliance,correctover,mcp,oauth2.1
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: click>=8.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# 📋 correctover-compliance-check
|
|
2
|
+
|
|
3
|
+
**MCP OAuth 2.1 & CCS v1.0 Compliance Checker.**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install correctover-compliance-check
|
|
7
|
+
correctover-compliance-check check-full
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Enforce the 2026-07-28 OAuth 2.1 mandate. Verify:
|
|
11
|
+
- OAuth 2.1 compliance (8 checks)
|
|
12
|
+
- CCS v1.0 standard compliance (5 checks)
|
|
13
|
+
- MCP protocol compliance (5 checks)
|
|
14
|
+
|
|
15
|
+
**Pricing**: Certification $2,999 + $999/year
|
|
16
|
+
|
|
17
|
+
[Get certified → correctover.com](https://correctover.com)
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compliance Check Engine — OAuth 2.1, CCS v1.0, MCP Protocol compliance.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ComplianceIssue:
|
|
14
|
+
check_id: str
|
|
15
|
+
category: str # oauth | ccs | mcp
|
|
16
|
+
severity: str # FAIL | WARN | INFO
|
|
17
|
+
title: str
|
|
18
|
+
detail: str
|
|
19
|
+
fix: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ComplianceReport:
|
|
24
|
+
target: str
|
|
25
|
+
target_type: str
|
|
26
|
+
scan_duration_ms: float
|
|
27
|
+
checks_total: int
|
|
28
|
+
checks_passed: int
|
|
29
|
+
checks_warned: int
|
|
30
|
+
checks_failed: int
|
|
31
|
+
verdict: str # PASS | PARTIAL | FAIL
|
|
32
|
+
issues: list = field(default_factory=list)
|
|
33
|
+
oauth_deadline: str = "2026-07-28"
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
return {
|
|
37
|
+
"target": self.target,
|
|
38
|
+
"target_type": self.target_type,
|
|
39
|
+
"scan_duration_ms": self.scan_duration_ms,
|
|
40
|
+
"checks_total": self.checks_total,
|
|
41
|
+
"checks_passed": self.checks_passed,
|
|
42
|
+
"checks_warned": self.checks_warned,
|
|
43
|
+
"checks_failed": self.checks_failed,
|
|
44
|
+
"verdict": self.verdict,
|
|
45
|
+
"issues": [
|
|
46
|
+
{
|
|
47
|
+
"check_id": i.check_id,
|
|
48
|
+
"category": i.category,
|
|
49
|
+
"severity": i.severity,
|
|
50
|
+
"title": i.title,
|
|
51
|
+
"detail": i.detail,
|
|
52
|
+
"fix": i.fix,
|
|
53
|
+
}
|
|
54
|
+
for i in self.issues
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ComplianceChecker:
|
|
60
|
+
"""Compliance check engine leveraging CCS v1.0 validator concepts."""
|
|
61
|
+
|
|
62
|
+
OAUTH_CHECKS = {
|
|
63
|
+
"OAUTH-001": {
|
|
64
|
+
"title": "Authorization Code Grant with PKCE required",
|
|
65
|
+
"check": lambda cfg: _check_grant_type(cfg, "authorization_code"),
|
|
66
|
+
"detail_fail": "Missing or unsupported authorization_code grant with PKCE.",
|
|
67
|
+
"fix": "Implement RFC 7636 PKCE with S256 code challenge on authorization_code flow.",
|
|
68
|
+
},
|
|
69
|
+
"OAUTH-002": {
|
|
70
|
+
"title": "No Implicit Grant",
|
|
71
|
+
"check": lambda cfg: _check_no_implicit(cfg),
|
|
72
|
+
"detail_fail": "Implicit grant detected — removed in OAuth 2.1.",
|
|
73
|
+
"fix": "Migrate from implicit to authorization_code + PKCE.",
|
|
74
|
+
},
|
|
75
|
+
"OAUTH-003": {
|
|
76
|
+
"title": "No Resource Owner Password Grant",
|
|
77
|
+
"check": lambda cfg: _check_no_password_grant(cfg),
|
|
78
|
+
"detail_fail": "Resource Owner Password grant detected — removed in OAuth 2.1.",
|
|
79
|
+
"fix": "Replace password grant with authorization_code + PKCE.",
|
|
80
|
+
},
|
|
81
|
+
"OAUTH-004": {
|
|
82
|
+
"title": "Refresh Token Rotation",
|
|
83
|
+
"check": lambda cfg: _check_refresh_rotation(cfg),
|
|
84
|
+
"detail_fail": "Refresh tokens not configured for rotation or sender-constraining.",
|
|
85
|
+
"fix": "Enable refresh token rotation with reuse detection per OAuth 2.1 §6.",
|
|
86
|
+
},
|
|
87
|
+
"OAUTH-005": {
|
|
88
|
+
"title": "PKCE Required (even for confidential clients)",
|
|
89
|
+
"check": lambda cfg: _check_pkce_required(cfg),
|
|
90
|
+
"detail_fail": "PKCE not enforced — OAuth 2.1 mandates PKCE for all clients.",
|
|
91
|
+
"fix": "Enforce PKCE (S256) on all authorization code exchanges.",
|
|
92
|
+
},
|
|
93
|
+
"OAUTH-006": {
|
|
94
|
+
"title": "Redirect URI Exact Matching",
|
|
95
|
+
"check": lambda cfg: _check_redirect_uri(cfg),
|
|
96
|
+
"detail_fail": "Redirect URI validation is permissive or missing.",
|
|
97
|
+
"fix": "Use exact redirect URI matching per OAuth 2.1 §4.1.",
|
|
98
|
+
},
|
|
99
|
+
"OAUTH-007": {
|
|
100
|
+
"title": "DCR Metadata Compliance",
|
|
101
|
+
"check": lambda cfg: _check_dcr_metadata(cfg),
|
|
102
|
+
"detail_fail": "Dynamic Client Registration metadata missing required fields.",
|
|
103
|
+
"fix": "Include client_name, redirect_uris, grant_types, and token_endpoint_auth_method in DCR metadata per RFC 7591.",
|
|
104
|
+
},
|
|
105
|
+
"OAUTH-008": {
|
|
106
|
+
"title": "Token Endpoint Auth Method",
|
|
107
|
+
"check": lambda cfg: _check_token_auth_method(cfg),
|
|
108
|
+
"detail_fail": "Token endpoint auth method is insecure (client_secret_post with no TLS verification).",
|
|
109
|
+
"fix": "Use private_key_jwt or mutual TLS for token endpoint authentication.",
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
CCS_CHECKS = {
|
|
114
|
+
"CCS-001": {
|
|
115
|
+
"title": "CCS v1.0 Schema Compliance",
|
|
116
|
+
"check": lambda cfg: _check_ccs_schema(cfg),
|
|
117
|
+
"detail_fail": "MCP Server config does not conform to CCS v1.0 schema.",
|
|
118
|
+
"fix": "Validate against CCS v1.0 JSON Schema: https://correctover.com/ccs/v1/schema.json",
|
|
119
|
+
},
|
|
120
|
+
"CCS-002": {
|
|
121
|
+
"title": "Transport Security — HTTPS Only",
|
|
122
|
+
"check": lambda cfg: _check_https_transport(cfg),
|
|
123
|
+
"detail_fail": "Non-HTTPS transport detected for remote MCP server.",
|
|
124
|
+
"fix": "Enforce TLS 1.3+ for all remote transport connections.",
|
|
125
|
+
},
|
|
126
|
+
"CCS-003": {
|
|
127
|
+
"title": "Tool Schema Validation Required",
|
|
128
|
+
"check": lambda cfg: _check_tool_schema_validation(cfg),
|
|
129
|
+
"detail_fail": "MCP tool definitions missing input/output JSON Schema.",
|
|
130
|
+
"fix": "Add inputSchema and outputSchema to all tool definitions per MCP spec.",
|
|
131
|
+
},
|
|
132
|
+
"CCS-004": {
|
|
133
|
+
"title": "Environment Variable Scoping",
|
|
134
|
+
"check": lambda cfg: _check_env_scoping(cfg),
|
|
135
|
+
"detail_fail": "Environment variables exposed without scope restrictions.",
|
|
136
|
+
"fix": "Scope env vars using allowlist/prefix filtering per CCS v1.0 §7.3.",
|
|
137
|
+
},
|
|
138
|
+
"CCS-005": {
|
|
139
|
+
"title": "Rate Limiting Configuration",
|
|
140
|
+
"check": lambda cfg: _check_rate_limiting(cfg),
|
|
141
|
+
"detail_fail": "No rate limiting configured for MCP server endpoints.",
|
|
142
|
+
"fix": "Add rate limiting: 100 req/min per tool, 1000 req/min per server.",
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
MCP_CHECKS = {
|
|
147
|
+
"MCP-001": {
|
|
148
|
+
"title": "MCP Transport Compliance",
|
|
149
|
+
"check": lambda cfg: _check_transport_type(cfg),
|
|
150
|
+
"detail_fail": "Transport type not specified or unsupported (must be stdio, sse, or streamable-http).",
|
|
151
|
+
"fix": "Set transport to 'streamable-http' (recommended) or 'sse' per MCP spec 2025-11-25.",
|
|
152
|
+
},
|
|
153
|
+
"MCP-002": {
|
|
154
|
+
"title": "Tool Naming Convention",
|
|
155
|
+
"check": lambda cfg: _check_tool_naming(cfg),
|
|
156
|
+
"detail_fail": "Tool names do not follow snake_case convention per MCP spec.",
|
|
157
|
+
"fix": "Rename tools to snake_case (e.g., 'search_docs' not 'searchDocs').",
|
|
158
|
+
},
|
|
159
|
+
"MCP-003": {
|
|
160
|
+
"title": "Tool Description Completeness",
|
|
161
|
+
"check": lambda cfg: _check_tool_descriptions(cfg),
|
|
162
|
+
"detail_fail": "One or more tools missing description field.",
|
|
163
|
+
"fix": "Every tool must have a non-empty 'description' field.",
|
|
164
|
+
},
|
|
165
|
+
"MCP-004": {
|
|
166
|
+
"title": "Error Response Standard",
|
|
167
|
+
"check": lambda cfg: _check_error_format(cfg),
|
|
168
|
+
"detail_fail": "Error handling does not follow MCP error response standard.",
|
|
169
|
+
"fix": "Return errors as MCP-standard JSON-RPC error objects with code, message, and optional data.",
|
|
170
|
+
},
|
|
171
|
+
"MCP-005": {
|
|
172
|
+
"title": "Server Capability Declaration",
|
|
173
|
+
"check": lambda cfg: _check_capabilities(cfg),
|
|
174
|
+
"detail_fail": "Missing or incomplete server capabilities declaration.",
|
|
175
|
+
"fix": "Declare supported capabilities: tools, resources, prompts, logging per MCP initialize response.",
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def __init__(self):
|
|
180
|
+
self.issues: list[ComplianceIssue] = []
|
|
181
|
+
|
|
182
|
+
def check_oauth(self, config: dict) -> list[ComplianceIssue]:
|
|
183
|
+
issues = []
|
|
184
|
+
auth_config = config.get("auth", config.get("oauth", {}))
|
|
185
|
+
for check_id, spec in self.OAUTH_CHECKS.items():
|
|
186
|
+
passed, detail = spec["check"](auth_config)
|
|
187
|
+
if not passed:
|
|
188
|
+
issues.append(
|
|
189
|
+
ComplianceIssue(
|
|
190
|
+
check_id=check_id,
|
|
191
|
+
category="oauth",
|
|
192
|
+
severity="FAIL",
|
|
193
|
+
title=spec["title"],
|
|
194
|
+
detail=detail or spec["detail_fail"],
|
|
195
|
+
fix=spec["fix"],
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return issues
|
|
199
|
+
|
|
200
|
+
def check_ccs(self, config: dict) -> list[ComplianceIssue]:
|
|
201
|
+
issues = []
|
|
202
|
+
for check_id, spec in self.CCS_CHECKS.items():
|
|
203
|
+
passed, detail = spec["check"](config)
|
|
204
|
+
if not passed:
|
|
205
|
+
severity = "WARN" if check_id in ("CCS-004", "CCS-005") else "FAIL"
|
|
206
|
+
issues.append(
|
|
207
|
+
ComplianceIssue(
|
|
208
|
+
check_id=check_id,
|
|
209
|
+
category="ccs",
|
|
210
|
+
severity=severity,
|
|
211
|
+
title=spec["title"],
|
|
212
|
+
detail=detail or spec["detail_fail"],
|
|
213
|
+
fix=spec["fix"],
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
return issues
|
|
217
|
+
|
|
218
|
+
def check_mcp(self, config: dict) -> list[ComplianceIssue]:
|
|
219
|
+
issues = []
|
|
220
|
+
for check_id, spec in self.MCP_CHECKS.items():
|
|
221
|
+
passed, detail = spec["check"](config)
|
|
222
|
+
if not passed:
|
|
223
|
+
severity = "WARN" if check_id in ("MCP-004",) else "FAIL"
|
|
224
|
+
issues.append(
|
|
225
|
+
ComplianceIssue(
|
|
226
|
+
check_id=check_id,
|
|
227
|
+
category="mcp",
|
|
228
|
+
severity=severity,
|
|
229
|
+
title=spec["title"],
|
|
230
|
+
detail=detail or spec["detail_fail"],
|
|
231
|
+
fix=spec["fix"],
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return issues
|
|
235
|
+
|
|
236
|
+
def check_full(self, config: dict) -> ComplianceReport:
|
|
237
|
+
start = time.time()
|
|
238
|
+
self.issues = []
|
|
239
|
+
|
|
240
|
+
self.issues.extend(self.check_oauth(config))
|
|
241
|
+
self.issues.extend(self.check_ccs(config))
|
|
242
|
+
self.issues.extend(self.check_mcp(config))
|
|
243
|
+
|
|
244
|
+
total = len(self.OAUTH_CHECKS) + len(self.CCS_CHECKS) + len(self.MCP_CHECKS)
|
|
245
|
+
failed = sum(1 for i in self.issues if i.severity == "FAIL")
|
|
246
|
+
warned = sum(1 for i in self.issues if i.severity == "WARN")
|
|
247
|
+
passed = total - failed - warned
|
|
248
|
+
|
|
249
|
+
if failed == 0 and warned == 0:
|
|
250
|
+
verdict = "PASS"
|
|
251
|
+
elif failed == 0:
|
|
252
|
+
verdict = "PARTIAL"
|
|
253
|
+
else:
|
|
254
|
+
verdict = "FAIL"
|
|
255
|
+
|
|
256
|
+
return ComplianceReport(
|
|
257
|
+
target=config.get("name", "unknown"),
|
|
258
|
+
target_type="mcp_server",
|
|
259
|
+
scan_duration_ms=(time.time() - start) * 1000,
|
|
260
|
+
checks_total=total,
|
|
261
|
+
checks_passed=passed,
|
|
262
|
+
checks_warned=warned,
|
|
263
|
+
checks_failed=failed,
|
|
264
|
+
verdict=verdict,
|
|
265
|
+
issues=self.issues,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# --- OAuth 2.1 check helpers ---
|
|
270
|
+
|
|
271
|
+
def _check_grant_type(cfg: dict, expected: str) -> tuple[bool, str]:
|
|
272
|
+
grants = cfg.get("grant_types", cfg.get("grants", []))
|
|
273
|
+
if not isinstance(grants, list):
|
|
274
|
+
grants = [grants]
|
|
275
|
+
if expected not in grants:
|
|
276
|
+
return False, f"Grant type '{expected}' not found in {grants}"
|
|
277
|
+
if "pkce" not in str(cfg).lower() and "code_challenge" not in str(cfg).lower():
|
|
278
|
+
return False, "PKCE not detected alongside authorization_code"
|
|
279
|
+
return True, ""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _check_no_implicit(cfg: dict) -> tuple[bool, str]:
|
|
283
|
+
grants = cfg.get("grant_types", cfg.get("grants", []))
|
|
284
|
+
if not isinstance(grants, list):
|
|
285
|
+
grants = [grants]
|
|
286
|
+
if "implicit" in [g.lower() for g in grants]:
|
|
287
|
+
return False, "Implicit grant found in grant_types"
|
|
288
|
+
return True, ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _check_no_password_grant(cfg: dict) -> tuple[bool, str]:
|
|
292
|
+
grants = cfg.get("grant_types", cfg.get("grants", []))
|
|
293
|
+
if not isinstance(grants, list):
|
|
294
|
+
grants = [grants]
|
|
295
|
+
if any(g.lower() in ("password", "resource_owner", "ropc") for g in grants):
|
|
296
|
+
return False, "Password grant found in grant_types"
|
|
297
|
+
return True, ""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _check_refresh_rotation(cfg: dict) -> tuple[bool, str]:
|
|
301
|
+
if cfg.get("refresh_token_rotation") or cfg.get("rotation_enabled"):
|
|
302
|
+
return True, ""
|
|
303
|
+
return False, ""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _check_pkce_required(cfg: dict) -> tuple[bool, str]:
|
|
307
|
+
pkce_method = cfg.get("pkce_method", cfg.get("code_challenge_method", ""))
|
|
308
|
+
if pkce_method.upper() not in ("S256",):
|
|
309
|
+
return False, f"PKCE method is '{pkce_method}', expected 'S256'"
|
|
310
|
+
return True, ""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _check_redirect_uri(cfg: dict) -> tuple[bool, str]:
|
|
314
|
+
uris = cfg.get("redirect_uris", cfg.get("redirect_uri", []))
|
|
315
|
+
if not uris:
|
|
316
|
+
return True, ""
|
|
317
|
+
for uri in uris:
|
|
318
|
+
if "*" in str(uri) or "wildcard" in str(uri).lower():
|
|
319
|
+
return False, f"Wildcard redirect URI detected: {uri}"
|
|
320
|
+
return True, ""
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _check_dcr_metadata(cfg: dict) -> tuple[bool, str]:
|
|
324
|
+
required = ["client_name", "redirect_uris", "grant_types"]
|
|
325
|
+
missing = [f for f in required if not cfg.get(f)]
|
|
326
|
+
if missing:
|
|
327
|
+
return False, f"Missing DCR metadata fields: {', '.join(missing)}"
|
|
328
|
+
return True, ""
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _check_token_auth_method(cfg: dict) -> tuple[bool, str]:
|
|
332
|
+
method = cfg.get("token_endpoint_auth_method", cfg.get("auth_method", ""))
|
|
333
|
+
if method in ("client_secret_basic", "client_secret_post", ""):
|
|
334
|
+
return False, f"Auth method '{method or 'not set'}' is insecure for production"
|
|
335
|
+
return True, ""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --- CCS v1.0 check helpers ---
|
|
339
|
+
|
|
340
|
+
def _check_ccs_schema(cfg: dict) -> tuple[bool, str]:
|
|
341
|
+
if cfg.get("ccs_version") or cfg.get("correctover") or cfg.get("compliance"):
|
|
342
|
+
return True, ""
|
|
343
|
+
return False, ""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _check_https_transport(cfg: dict) -> tuple[bool, str]:
|
|
347
|
+
url = cfg.get("url", cfg.get("endpoint", ""))
|
|
348
|
+
if not url:
|
|
349
|
+
return True, ""
|
|
350
|
+
if not url.startswith("https://"):
|
|
351
|
+
return False, f"Transport URL uses '{url.split('://')[0]}' instead of HTTPS"
|
|
352
|
+
return True, ""
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _check_tool_schema_validation(cfg: dict) -> tuple[bool, str]:
|
|
356
|
+
tools = cfg.get("tools", [])
|
|
357
|
+
if not tools:
|
|
358
|
+
return True, ""
|
|
359
|
+
missing_schema = []
|
|
360
|
+
for t in tools:
|
|
361
|
+
if not t.get("inputSchema") and not t.get("input_schema"):
|
|
362
|
+
missing_schema.append(t.get("name", "unnamed"))
|
|
363
|
+
if missing_schema:
|
|
364
|
+
return False, f"Tools missing input schema: {', '.join(missing_schema)}"
|
|
365
|
+
return True, ""
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _check_env_scoping(cfg: dict) -> tuple[bool, str]:
|
|
369
|
+
env_vars = cfg.get("env", cfg.get("environment", {}))
|
|
370
|
+
if not env_vars:
|
|
371
|
+
return True, ""
|
|
372
|
+
if not cfg.get("env_scoping") and not cfg.get("env_allowlist"):
|
|
373
|
+
return False, ""
|
|
374
|
+
return True, ""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _check_rate_limiting(cfg: dict) -> tuple[bool, str]:
|
|
378
|
+
rl = cfg.get("rate_limiting", cfg.get("rate_limit", {}))
|
|
379
|
+
if not rl:
|
|
380
|
+
return False, ""
|
|
381
|
+
return True, ""
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# --- MCP Protocol check helpers ---
|
|
385
|
+
|
|
386
|
+
def _check_transport_type(cfg: dict) -> tuple[bool, str]:
|
|
387
|
+
transport = cfg.get("transport", cfg.get("protocol", ""))
|
|
388
|
+
valid = ("stdio", "sse", "streamable-http", "streamable_http")
|
|
389
|
+
if transport.lower() not in valid:
|
|
390
|
+
return False, f"Transport '{transport}' not valid. Must be one of {valid}"
|
|
391
|
+
return True, ""
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _check_tool_naming(cfg: dict) -> tuple[bool, str]:
|
|
395
|
+
tools = cfg.get("tools", [])
|
|
396
|
+
bad_names = []
|
|
397
|
+
for t in tools:
|
|
398
|
+
name = t.get("name", "")
|
|
399
|
+
if name and not name.islower() and "_" not in name:
|
|
400
|
+
if any(c.isupper() for c in name):
|
|
401
|
+
bad_names.append(name)
|
|
402
|
+
if bad_names:
|
|
403
|
+
return False, f"Non-snake_case tool names: {', '.join(bad_names)}"
|
|
404
|
+
return True, ""
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _check_tool_descriptions(cfg: dict) -> tuple[bool, str]:
|
|
408
|
+
tools = cfg.get("tools", [])
|
|
409
|
+
missing = []
|
|
410
|
+
for t in tools:
|
|
411
|
+
desc = t.get("description", "")
|
|
412
|
+
if not desc or not desc.strip():
|
|
413
|
+
missing.append(t.get("name", "unnamed"))
|
|
414
|
+
if missing:
|
|
415
|
+
return False, f"Tools missing description: {', '.join(missing)}"
|
|
416
|
+
return True, ""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _check_error_format(cfg: dict) -> tuple[bool, str]:
|
|
420
|
+
if not cfg.get("error_handler") and not cfg.get("error_format"):
|
|
421
|
+
return False, ""
|
|
422
|
+
return True, ""
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _check_capabilities(cfg: dict) -> tuple[bool, str]:
|
|
426
|
+
caps = cfg.get("capabilities", cfg.get("server_capabilities", {}))
|
|
427
|
+
if not caps:
|
|
428
|
+
return False, ""
|
|
429
|
+
return True, ""
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
correctover-compliance-check CLI — MCP OAuth 2.1 & CCS v1.0 compliance checker.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
correctover-compliance-check check-oauth <config.json>
|
|
6
|
+
correctover-compliance-check check-ccs <config.json>
|
|
7
|
+
correctover-compliance-check check-full <config.json>
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from .checker import ComplianceChecker, ComplianceReport
|
|
18
|
+
|
|
19
|
+
VERSION = "1.0.0"
|
|
20
|
+
CTA_URL = "https://correctover.com"
|
|
21
|
+
PRICING_AUTH = "$2,999"
|
|
22
|
+
PRICING_ANNUAL = "$999"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _print_report(report: ComplianceReport, output_format: str, output: Optional[str]):
|
|
26
|
+
if output_format == "json":
|
|
27
|
+
content = json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
|
|
28
|
+
if output:
|
|
29
|
+
Path(output).write_text(content)
|
|
30
|
+
click.echo(f"Report written to {output}")
|
|
31
|
+
else:
|
|
32
|
+
click.echo(content)
|
|
33
|
+
|
|
34
|
+
elif output_format == "markdown":
|
|
35
|
+
lines = [
|
|
36
|
+
f"# Correctover Compliance Check Report",
|
|
37
|
+
f"",
|
|
38
|
+
f"**Target**: `{report.target}` | **Type**: {report.target_type}",
|
|
39
|
+
f"**Duration**: {report.scan_duration_ms:.1f}ms | **Checks**: {report.checks_total}",
|
|
40
|
+
f"**OAuth 2.1 Deadline**: {report.oauth_deadline}",
|
|
41
|
+
f"",
|
|
42
|
+
f"## Verdict: {report.verdict}",
|
|
43
|
+
f"",
|
|
44
|
+
f"| Metric | Value |",
|
|
45
|
+
f"|--------|-------|",
|
|
46
|
+
f"| Passed | {report.checks_passed} |",
|
|
47
|
+
f"| Warned | {report.checks_warned} |",
|
|
48
|
+
f"| Failed | {report.checks_failed} |",
|
|
49
|
+
f"",
|
|
50
|
+
]
|
|
51
|
+
if report.issues:
|
|
52
|
+
lines.append("## Issues")
|
|
53
|
+
lines.append("")
|
|
54
|
+
for i in report.issues:
|
|
55
|
+
lines.append(f"### [{i.severity}] {i.category.upper()}: {i.title}")
|
|
56
|
+
lines.append(f"- **Check**: {i.check_id}")
|
|
57
|
+
lines.append(f"- **Detail**: {i.detail}")
|
|
58
|
+
lines.append(f"- **Fix**: {i.fix}")
|
|
59
|
+
lines.append("")
|
|
60
|
+
|
|
61
|
+
lines.append("---")
|
|
62
|
+
lines.append(f"*Checked by [Correctover Compliance Check]({CTA_URL}) v{VERSION}*")
|
|
63
|
+
lines.append(f"*Pricing: Auth Fee {PRICING_AUTH} + Annual {PRICING_ANNUAL}*")
|
|
64
|
+
|
|
65
|
+
content = "\n".join(lines)
|
|
66
|
+
if output:
|
|
67
|
+
Path(output).write_text(content)
|
|
68
|
+
click.echo(f"Report written to {output}")
|
|
69
|
+
else:
|
|
70
|
+
click.echo(content)
|
|
71
|
+
|
|
72
|
+
else: # terminal
|
|
73
|
+
GREEN = "\033[92m"
|
|
74
|
+
RED = "\033[91m"
|
|
75
|
+
YELLOW = "\033[93m"
|
|
76
|
+
CYAN = "\033[96m"
|
|
77
|
+
BOLD = "\033[1m"
|
|
78
|
+
DIM = "\033[2m"
|
|
79
|
+
RESET = "\033[0m"
|
|
80
|
+
|
|
81
|
+
verdict_color = GREEN if report.verdict == "PASS" else (YELLOW if report.verdict == "PARTIAL" else RED)
|
|
82
|
+
|
|
83
|
+
click.echo(f"\n{CYAN}{BOLD}╔══════════════════════════════════════════════╗{RESET}")
|
|
84
|
+
click.echo(f"{CYAN}{BOLD}║{RESET} {BOLD}Correctover Compliance Check Report{RESET} {CYAN}{BOLD}║{RESET}")
|
|
85
|
+
click.echo(f"{CYAN}{BOLD}╚══════════════════════════════════════════════╝{RESET}\n")
|
|
86
|
+
click.echo(f" Target: {BOLD}{report.target}{RESET} ({report.target_type})")
|
|
87
|
+
click.echo(f" Duration: {report.scan_duration_ms:.1f}ms | Checks: {report.checks_total}")
|
|
88
|
+
click.echo(f" OAuth 2.1 Deadline: {RED}{report.oauth_deadline}{RESET}")
|
|
89
|
+
click.echo()
|
|
90
|
+
click.echo(f" Verdict: {verdict_color}{BOLD}{report.verdict}{RESET}")
|
|
91
|
+
click.echo(f" {GREEN}Passed: {report.checks_passed}{RESET} "
|
|
92
|
+
f"{YELLOW}Warned: {report.checks_warned}{RESET} "
|
|
93
|
+
f"{RED}Failed: {report.checks_failed}{RESET}")
|
|
94
|
+
click.echo()
|
|
95
|
+
|
|
96
|
+
if report.issues:
|
|
97
|
+
for i in report.issues:
|
|
98
|
+
sev_color = RED if i.severity == "FAIL" else YELLOW
|
|
99
|
+
click.echo(f" {sev_color}[{i.severity}]{RESET} {DIM}[{i.check_id}]{RESET} {BOLD}{i.title}{RESET}")
|
|
100
|
+
click.echo(f" {DIM}{i.detail}{RESET}")
|
|
101
|
+
click.echo(f" {GREEN}Fix: {i.fix}{RESET}")
|
|
102
|
+
click.echo()
|
|
103
|
+
else:
|
|
104
|
+
click.echo(f" {GREEN}All checks passed — fully compliant.{RESET}\n")
|
|
105
|
+
|
|
106
|
+
# CTA
|
|
107
|
+
click.echo(f"{CYAN}{BOLD}┌─────────────────────────────────────────────────┐{RESET}")
|
|
108
|
+
click.echo(f"{CYAN}{BOLD}│{RESET} 🛡️ Get compliant → {CTA_URL} {CYAN}{BOLD}│{RESET}")
|
|
109
|
+
click.echo(f"{CYAN}{BOLD}│{RESET} {DIM}Auth Fee {PRICING_AUTH} + Annual {PRICING_ANNUAL}{RESET} {CYAN}{BOLD}│{RESET}")
|
|
110
|
+
click.echo(f"{CYAN}{BOLD}│{RESET} {DIM}Deadline: {report.oauth_deadline} — OAuth 2.1 mandate{RESET} {CYAN}{BOLD}│{RESET}")
|
|
111
|
+
click.echo(f"{CYAN}{BOLD}└─────────────────────────────────────────────────┘{RESET}\n")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@click.group()
|
|
115
|
+
@click.version_option(version=VERSION, prog_name="correctover-compliance-check")
|
|
116
|
+
def cli():
|
|
117
|
+
"""Correctover Compliance Check — MCP OAuth 2.1 & CCS v1.0 compliance.
|
|
118
|
+
|
|
119
|
+
Enforce the 2026-07-28 OAuth 2.1 mandate.
|
|
120
|
+
Verify MCP protocol compliance (transport, auth, tool schema).
|
|
121
|
+
"""
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@cli.command()
|
|
126
|
+
@click.argument("config_file", type=click.Path(exists=True))
|
|
127
|
+
@click.option("--format", "output_format", type=click.Choice(["terminal", "json", "markdown"]), default="terminal")
|
|
128
|
+
@click.option("--output", "-o", default=None, help="Output file path")
|
|
129
|
+
def check_oauth(config_file: str, output_format: str, output: Optional[str]):
|
|
130
|
+
"""Check OAuth 2.1 compliance only."""
|
|
131
|
+
config = json.loads(Path(config_file).read_text())
|
|
132
|
+
checker = ComplianceChecker()
|
|
133
|
+
issues = checker.check_oauth(config)
|
|
134
|
+
report = ComplianceReport(
|
|
135
|
+
target=config.get("name", config_file),
|
|
136
|
+
target_type="mcp_server",
|
|
137
|
+
scan_duration_ms=0,
|
|
138
|
+
checks_total=len(ComplianceChecker.OAUTH_CHECKS),
|
|
139
|
+
checks_passed=len(ComplianceChecker.OAUTH_CHECKS) - len(issues),
|
|
140
|
+
checks_warned=0,
|
|
141
|
+
checks_failed=len(issues),
|
|
142
|
+
verdict="FAIL" if issues else "PASS",
|
|
143
|
+
issues=issues,
|
|
144
|
+
)
|
|
145
|
+
_print_report(report, output_format, output)
|
|
146
|
+
sys.exit(1 if issues else 0)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@cli.command()
|
|
150
|
+
@click.argument("config_file", type=click.Path(exists=True))
|
|
151
|
+
@click.option("--format", "output_format", type=click.Choice(["terminal", "json", "markdown"]), default="terminal")
|
|
152
|
+
@click.option("--output", "-o", default=None, help="Output file path")
|
|
153
|
+
def check_ccs(config_file: str, output_format: str, output: Optional[str]):
|
|
154
|
+
"""Check CCS v1.0 compliance only."""
|
|
155
|
+
config = json.loads(Path(config_file).read_text())
|
|
156
|
+
checker = ComplianceChecker()
|
|
157
|
+
issues = checker.check_ccs(config)
|
|
158
|
+
failed = sum(1 for i in issues if i.severity == "FAIL")
|
|
159
|
+
warned = sum(1 for i in issues if i.severity == "WARN")
|
|
160
|
+
report = ComplianceReport(
|
|
161
|
+
target=config.get("name", config_file),
|
|
162
|
+
target_type="mcp_server",
|
|
163
|
+
scan_duration_ms=0,
|
|
164
|
+
checks_total=len(ComplianceChecker.CCS_CHECKS),
|
|
165
|
+
checks_passed=len(ComplianceChecker.CCS_CHECKS) - len(issues),
|
|
166
|
+
checks_warned=warned,
|
|
167
|
+
checks_failed=failed,
|
|
168
|
+
verdict="FAIL" if failed else ("PARTIAL" if warned else "PASS"),
|
|
169
|
+
issues=issues,
|
|
170
|
+
)
|
|
171
|
+
_print_report(report, output_format, output)
|
|
172
|
+
sys.exit(1 if failed else 0)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@cli.command()
|
|
176
|
+
@click.argument("config_file", type=click.Path(exists=True))
|
|
177
|
+
@click.option("--format", "output_format", type=click.Choice(["terminal", "json", "markdown"]), default="terminal")
|
|
178
|
+
@click.option("--output", "-o", default=None, help="Output file path")
|
|
179
|
+
def check_full(config_file: str, output_format: str, output: Optional[str]):
|
|
180
|
+
"""Run full compliance check (OAuth 2.1 + CCS v1.0 + MCP Protocol)."""
|
|
181
|
+
config = json.loads(Path(config_file).read_text())
|
|
182
|
+
checker = ComplianceChecker()
|
|
183
|
+
report = checker.check_full(config)
|
|
184
|
+
_print_report(report, output_format, output)
|
|
185
|
+
sys.exit(1 if report.checks_failed > 0 else 0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
cli()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "correctover-compliance-check"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "MCP OAuth 2.1 & CCS v1.0 Compliance Checker — enforce the 2026-07-28 mandate"
|
|
9
|
+
license = { text = "Apache-2.0" }
|
|
10
|
+
authors = [{ name = "Correctover", email = "wangguigui@correctover.com" }]
|
|
11
|
+
keywords = ["compliance", "oauth2.1", "ccs", "mcp", "correctover", "audit"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Topic :: Security",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.11"
|
|
21
|
+
dependencies = ["click>=8.0"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://correctover.com"
|
|
25
|
+
Repository = "https://github.com/Correctover/compliance-check"
|
|
26
|
+
Issues = "https://github.com/Correctover/compliance-check/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
correctover-compliance-check = "correctover_agent.cli:cli"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["correctover_agent"]
|