cleancloud 1.0.1__py3-none-any.whl

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.
Files changed (56) hide show
  1. cleancloud/__init__.py +0 -0
  2. cleancloud/cleancloud.yaml +11 -0
  3. cleancloud/cli.py +22 -0
  4. cleancloud/config/schema.py +65 -0
  5. cleancloud/core/__init__.py +0 -0
  6. cleancloud/core/confidence.py +14 -0
  7. cleancloud/core/evidence.py +9 -0
  8. cleancloud/core/finding.py +44 -0
  9. cleancloud/core/risk.py +7 -0
  10. cleancloud/doctor/__init__.py +3 -0
  11. cleancloud/doctor/aws.py +423 -0
  12. cleancloud/doctor/azure.py +270 -0
  13. cleancloud/doctor/command.py +45 -0
  14. cleancloud/doctor/common.py +19 -0
  15. cleancloud/doctor/runner.py +128 -0
  16. cleancloud/filtering/tags.py +57 -0
  17. cleancloud/output/csv.py +31 -0
  18. cleancloud/output/feedback.py +29 -0
  19. cleancloud/output/human.py +32 -0
  20. cleancloud/output/json.py +23 -0
  21. cleancloud/output/progress.py +3 -0
  22. cleancloud/output/summary.py +92 -0
  23. cleancloud/policy/__init__.py +0 -0
  24. cleancloud/policy/exit_policy.py +56 -0
  25. cleancloud/providers/__init__.py +0 -0
  26. cleancloud/providers/aws/__init__.py +0 -0
  27. cleancloud/providers/aws/rules/__init__.py +0 -0
  28. cleancloud/providers/aws/rules/cloudwatch_inactive.py +70 -0
  29. cleancloud/providers/aws/rules/ebs_snapshot_old.py +75 -0
  30. cleancloud/providers/aws/rules/ebs_unattached.py +79 -0
  31. cleancloud/providers/aws/rules/untagged_resources.py +145 -0
  32. cleancloud/providers/aws/scan.py +255 -0
  33. cleancloud/providers/aws/session.py +9 -0
  34. cleancloud/providers/aws/validate.py +86 -0
  35. cleancloud/providers/azure/__init__.py +0 -0
  36. cleancloud/providers/azure/rules/__init__.py +0 -0
  37. cleancloud/providers/azure/rules/ebs_snapshots_old.py +97 -0
  38. cleancloud/providers/azure/rules/public_ip_unused.py +79 -0
  39. cleancloud/providers/azure/rules/unattached_managed_disks.py +103 -0
  40. cleancloud/providers/azure/rules/untagged_resources.py +142 -0
  41. cleancloud/providers/azure/scan.py +217 -0
  42. cleancloud/providers/azure/session.py +77 -0
  43. cleancloud/providers/azure/validate.py +113 -0
  44. cleancloud/safety/__init__.py +0 -0
  45. cleancloud/safety/aws/__init__.py +0 -0
  46. cleancloud/safety/aws/allowlist.py +18 -0
  47. cleancloud/safety/azure/__init__.py +0 -0
  48. cleancloud/safety/azure/allowlist.py +13 -0
  49. cleancloud/scan/__init__.py +0 -0
  50. cleancloud/scan/command.py +257 -0
  51. cleancloud-1.0.1.dist-info/METADATA +725 -0
  52. cleancloud-1.0.1.dist-info/RECORD +56 -0
  53. cleancloud-1.0.1.dist-info/WHEEL +5 -0
  54. cleancloud-1.0.1.dist-info/entry_points.txt +2 -0
  55. cleancloud-1.0.1.dist-info/licenses/LICENSE +21 -0
  56. cleancloud-1.0.1.dist-info/top_level.txt +1 -0
cleancloud/__init__.py ADDED
File without changes
@@ -0,0 +1,11 @@
1
+ # cleancloud.yaml
2
+ version: 1
3
+
4
+ tag_filtering:
5
+ enabled: true
6
+ ignore:
7
+ - key: env
8
+ value: production
9
+ - key: team
10
+ value: platform
11
+ - key: team
cleancloud/cli.py ADDED
@@ -0,0 +1,22 @@
1
+ import click
2
+
3
+ from cleancloud.doctor.command import doctor
4
+ from cleancloud.scan.command import scan
5
+
6
+
7
+ @click.group()
8
+ def cli():
9
+ """CleanCloud – Safe cloud hygiene scanner"""
10
+ pass
11
+
12
+
13
+ cli.add_command(doctor)
14
+ cli.add_command(scan)
15
+
16
+
17
+ def main():
18
+ cli()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,65 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Dict, List, Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class IgnoreTagRuleConfig:
7
+ key: str
8
+ value: Optional[str] = None
9
+
10
+
11
+ @dataclass
12
+ class TagFilteringConfig:
13
+ enabled: bool
14
+ ignore: List[IgnoreTagRuleConfig]
15
+
16
+
17
+ @dataclass
18
+ class CleanCloudConfig:
19
+ tag_filtering: Optional[TagFilteringConfig] = None
20
+
21
+ @classmethod
22
+ def empty(cls) -> "CleanCloudConfig":
23
+ return cls(tag_filtering=None)
24
+
25
+
26
+ def load_config(data: Dict[str, Any]) -> CleanCloudConfig:
27
+ allowed_top_level = {"version", "tag_filtering"}
28
+ unknown = set(data.keys()) - allowed_top_level
29
+ if unknown:
30
+ raise ValueError(f"Unknown config fields: {unknown}")
31
+
32
+ tf = data.get("tag_filtering")
33
+ if not tf:
34
+ return CleanCloudConfig.empty()
35
+
36
+ if not isinstance(tf, dict):
37
+ raise ValueError("tag_filtering must be a mapping")
38
+
39
+ enabled = tf.get("enabled", True)
40
+ ignore = tf.get("ignore", [])
41
+
42
+ if not isinstance(ignore, list):
43
+ raise ValueError("tag_filtering.ignore must be a list")
44
+
45
+ rules: List[IgnoreTagRuleConfig] = []
46
+ for entry in ignore:
47
+ if not isinstance(entry, dict):
48
+ raise ValueError("Each ignore entry must be a mapping")
49
+
50
+ if "key" not in entry:
51
+ raise ValueError("ignore entry must contain 'key'")
52
+
53
+ rules.append(
54
+ IgnoreTagRuleConfig(
55
+ key=str(entry["key"]),
56
+ value=str(entry["value"]) if "value" in entry else None,
57
+ )
58
+ )
59
+
60
+ return CleanCloudConfig(
61
+ tag_filtering=TagFilteringConfig(
62
+ enabled=bool(enabled),
63
+ ignore=rules,
64
+ )
65
+ )
File without changes
@@ -0,0 +1,14 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ConfidenceLevel(str, Enum):
5
+ LOW = "low"
6
+ MEDIUM = "medium"
7
+ HIGH = "high"
8
+
9
+
10
+ CONFIDENCE_ORDER = {
11
+ "LOW": 1,
12
+ "MEDIUM": 2,
13
+ "HIGH": 3,
14
+ }
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Optional
3
+
4
+
5
+ @dataclass
6
+ class Evidence:
7
+ signals_used: List[str]
8
+ signals_not_checked: List[str]
9
+ time_window: Optional[str] = None
@@ -0,0 +1,44 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import Any, Dict, Optional
4
+
5
+ from cleancloud.core.confidence import ConfidenceLevel
6
+ from cleancloud.core.evidence import Evidence
7
+ from cleancloud.core.risk import RiskLevel
8
+
9
+
10
+ @dataclass
11
+ class Finding:
12
+ provider: str
13
+ rule_id: str
14
+ resource_type: str
15
+ resource_id: str
16
+ region: Optional[str]
17
+
18
+ title: str
19
+ summary: str
20
+ reason: str
21
+
22
+ risk: RiskLevel
23
+ confidence: ConfidenceLevel
24
+
25
+ detected_at: datetime
26
+ details: Dict[str, Any]
27
+ evidence: Evidence
28
+
29
+ def to_dict(self) -> Dict[str, Any]:
30
+ return {
31
+ "provider": self.provider,
32
+ "rule_id": self.rule_id,
33
+ "resource_type": self.resource_type,
34
+ "resource_id": self.resource_id,
35
+ "region": self.region,
36
+ "title": self.title,
37
+ "summary": self.summary,
38
+ "reason": self.reason,
39
+ "risk": self.risk.value,
40
+ "confidence": self.confidence.value,
41
+ "detected_at": self.detected_at.isoformat(),
42
+ "details": self.details,
43
+ "evidence": self.evidence,
44
+ }
@@ -0,0 +1,7 @@
1
+ from enum import Enum
2
+
3
+
4
+ class RiskLevel(str, Enum):
5
+ LOW = "low"
6
+ MEDIUM = "medium"
7
+ HIGH = "high"
@@ -0,0 +1,3 @@
1
+ from cleancloud.doctor.runner import run_doctor
2
+
3
+ __all__ = ["run_doctor"]
@@ -0,0 +1,423 @@
1
+ import os
2
+ import sys
3
+ from typing import Optional
4
+
5
+ import click
6
+
7
+ from cleancloud.doctor.common import fail, info, success, warn
8
+ from cleancloud.policy.exit_policy import EXIT_ERROR
9
+ from cleancloud.providers.aws.session import create_aws_session
10
+ from cleancloud.providers.aws.validate import KNOWN_AWS_REGIONS
11
+
12
+
13
+ def detect_aws_auth_method(session) -> tuple[str, str, dict]:
14
+ try:
15
+ credentials = session.get_credentials()
16
+
17
+ if credentials is None:
18
+ return "none", "No credentials found", {}
19
+
20
+ # Get what boto3 ACTUALLY used (not just env vars)
21
+ provider_name = credentials.method
22
+
23
+ # Determine if credentials are temporary
24
+ is_temporary = hasattr(credentials, "token") and credentials.token is not None
25
+
26
+ metadata = {
27
+ "provider_name": provider_name,
28
+ "is_temporary": is_temporary,
29
+ "recommended": False,
30
+ "ci_cd_ready": False,
31
+ "security_grade": "unknown",
32
+ }
33
+
34
+ # OIDC / Web Identity (GitHub Actions, GitLab CI, EKS)
35
+ if provider_name == "assume-role-with-web-identity":
36
+ metadata.update(
37
+ {
38
+ "recommended": True,
39
+ "ci_cd_ready": True,
40
+ "security_grade": "excellent",
41
+ "credential_lifetime": "1 hour (temporary)",
42
+ "rotation_required": False,
43
+ }
44
+ )
45
+ return "oidc", "OIDC (AssumeRoleWithWebIdentity)", metadata
46
+
47
+ # EC2 Instance Profile
48
+ elif provider_name == "iam-role":
49
+ metadata.update(
50
+ {
51
+ "recommended": True,
52
+ "ci_cd_ready": False,
53
+ "security_grade": "excellent",
54
+ "credential_lifetime": "temporary (auto-rotated)",
55
+ "rotation_required": False,
56
+ }
57
+ )
58
+ return "instance_profile", "EC2 Instance Profile", metadata
59
+
60
+ # ECS Task Role
61
+ elif provider_name == "container-role":
62
+ metadata.update(
63
+ {
64
+ "recommended": True,
65
+ "ci_cd_ready": False,
66
+ "security_grade": "excellent",
67
+ "credential_lifetime": "temporary (auto-rotated)",
68
+ "rotation_required": False,
69
+ }
70
+ )
71
+ return "ecs_task_role", "ECS Task Role", metadata
72
+
73
+ # AssumeRole (cross-account or role switching)
74
+ elif provider_name == "assume-role":
75
+ metadata.update(
76
+ {
77
+ "recommended": True,
78
+ "ci_cd_ready": True,
79
+ "security_grade": "good",
80
+ "credential_lifetime": "1-12 hours (temporary)",
81
+ "rotation_required": False,
82
+ }
83
+ )
84
+ return "assume_role", "AssumeRole (IAM Role)", metadata
85
+
86
+ # AWS CLI Profile (~/.aws/credentials)
87
+ elif provider_name == "shared-credentials-file":
88
+ profile = os.getenv("AWS_PROFILE", "default")
89
+ metadata.update(
90
+ {
91
+ "recommended": False,
92
+ "ci_cd_ready": False,
93
+ "security_grade": "acceptable",
94
+ "credential_lifetime": "long-lived (access keys)",
95
+ "rotation_required": True,
96
+ "profile_name": profile,
97
+ }
98
+ )
99
+ return "profile", f"AWS CLI Profile ({profile})", metadata
100
+
101
+ # Environment variables (AWS_ACCESS_KEY_ID/SECRET)
102
+ elif provider_name == "env":
103
+ if is_temporary:
104
+ metadata.update(
105
+ {
106
+ "recommended": True,
107
+ "ci_cd_ready": True,
108
+ "security_grade": "good",
109
+ "credential_lifetime": "temporary (with session token)",
110
+ "rotation_required": False,
111
+ }
112
+ )
113
+ return "temporary_keys", "Temporary Credentials (Environment)", metadata
114
+ else:
115
+ metadata.update(
116
+ {
117
+ "recommended": False,
118
+ "ci_cd_ready": False,
119
+ "security_grade": "poor",
120
+ "credential_lifetime": "long-lived (access keys)",
121
+ "rotation_required": True,
122
+ "rotation_interval": "90 days",
123
+ }
124
+ )
125
+ return "static_keys", "Static Access Keys (Environment)", metadata
126
+
127
+ # Explicitly configured credentials
128
+ elif provider_name in ("explicit", "static"):
129
+ if is_temporary:
130
+ metadata.update(
131
+ {
132
+ "recommended": True,
133
+ "ci_cd_ready": True,
134
+ "security_grade": "good",
135
+ "credential_lifetime": "temporary",
136
+ "rotation_required": False,
137
+ }
138
+ )
139
+ return "temporary_keys", "Temporary Credentials", metadata
140
+ else:
141
+ metadata.update(
142
+ {
143
+ "recommended": False,
144
+ "ci_cd_ready": False,
145
+ "security_grade": "poor",
146
+ "credential_lifetime": "long-lived",
147
+ "rotation_required": True,
148
+ }
149
+ )
150
+ return "static_keys", "Static Access Keys", metadata
151
+
152
+ # Unknown/other
153
+ else:
154
+ metadata.update(
155
+ {"recommended": False, "ci_cd_ready": False, "security_grade": "unknown"}
156
+ )
157
+ return "unknown", f"Other ({provider_name})", metadata
158
+
159
+ except Exception as e:
160
+ return "error", f"Error detecting method: {e}", {"error": str(e)}
161
+
162
+
163
+ def run_aws_doctor(profile: Optional[str], region: Optional[str] = None) -> None:
164
+ if region is None:
165
+ region = "us-east-1"
166
+
167
+ # Validate region before proceeding
168
+ if region not in KNOWN_AWS_REGIONS:
169
+ click.echo(f"❌ Error: '{region}' is not a valid AWS region")
170
+ click.echo()
171
+ click.echo("Common AWS regions:")
172
+ click.echo(" us-east-1, us-east-2, us-west-1, us-west-2")
173
+ click.echo(" eu-west-1, eu-central-1, ap-southeast-1, ap-northeast-1")
174
+ click.echo()
175
+ click.echo("All known regions:")
176
+ regions_list = sorted(KNOWN_AWS_REGIONS)
177
+ for i in range(0, len(regions_list), 4):
178
+ click.echo(" " + ", ".join(regions_list[i : i + 4]))
179
+ click.echo()
180
+ click.echo("💡 Tip: Doctor validates credentials using a single region")
181
+ click.echo(" Default region is us-east-1 if not specified")
182
+ sys.exit(EXIT_ERROR)
183
+
184
+ info("")
185
+ info("=" * 70)
186
+ info("AWS ENVIRONMENT VALIDATION")
187
+ info("=" * 70)
188
+ info("")
189
+
190
+ # Step 1: Create session
191
+ info("🔐 Step 1: AWS Credential Resolution")
192
+ info("-" * 70)
193
+
194
+ try:
195
+ session = create_aws_session(profile=profile, region=region)
196
+ success("AWS session created successfully")
197
+ except Exception as e:
198
+ fail(f"Failed to create AWS session: {e}")
199
+
200
+ # Step 2: Detect authentication method
201
+ info("")
202
+ info("🔍 Step 2: Authentication Method Detection")
203
+ info("-" * 70)
204
+
205
+ method_id, description, metadata = detect_aws_auth_method(session)
206
+
207
+ # Display auth method with context
208
+ info(f"Authentication Method: {description}")
209
+
210
+ if metadata.get("provider_name"):
211
+ info(f" Boto3 Provider: {metadata['provider_name']}")
212
+
213
+ if metadata.get("is_temporary") is not None:
214
+ credential_type = "Temporary" if metadata["is_temporary"] else "Long-lived"
215
+ info(f" Credential Type: {credential_type}")
216
+
217
+ if metadata.get("credential_lifetime"):
218
+ info(f" Lifetime: {metadata['credential_lifetime']}")
219
+
220
+ if metadata.get("rotation_required"):
221
+ info(f" Rotation Required: Yes (every {metadata.get('rotation_interval', '90 days')})")
222
+ else:
223
+ info(" Rotation Required: No (auto-rotated)")
224
+
225
+ # Security assessment
226
+ info("")
227
+ security_grade = metadata.get("security_grade", "unknown")
228
+
229
+ if security_grade == "excellent":
230
+ success("Security Grade: EXCELLENT ✅")
231
+ success(" ✓ Temporary credentials")
232
+ success(" ✓ Auto-rotated")
233
+ success(" ✓ No secret storage required")
234
+
235
+ elif security_grade == "good":
236
+ success("Security Grade: GOOD ✅")
237
+ info(" ✓ Temporary credentials")
238
+ if not metadata.get("rotation_required"):
239
+ info(" ✓ Auto-rotated")
240
+
241
+ elif security_grade == "acceptable":
242
+ warn("Security Grade: ACCEPTABLE ⚠️")
243
+ warn(" ⚠ Long-lived credentials")
244
+ warn(" ⚠ Manual rotation required")
245
+ info("")
246
+ info(" Recommendation for local development:")
247
+ info(" Current setup is acceptable")
248
+
249
+ elif security_grade == "poor":
250
+ warn("Security Grade: POOR ⚠️")
251
+ warn(" ⚠ Long-lived access keys")
252
+ warn(" ⚠ Requires 90-day rotation")
253
+ warn(" ⚠ High blast radius if compromised")
254
+ info("")
255
+ info(" Recommendation for CI/CD:")
256
+ info(" Switch to OIDC (OpenID Connect)")
257
+ info(" See: https://docs.cleancloud.io/aws#oidc")
258
+
259
+ else:
260
+ info(f"Security Grade: {security_grade.upper()}")
261
+
262
+ # CI/CD readiness
263
+ info("")
264
+ if metadata.get("ci_cd_ready"):
265
+ success("CI/CD Ready: YES ✅")
266
+ # Safety guarantees (informational only)
267
+ info("")
268
+ info("🛡️ CleanCloud Safety Guarantees")
269
+ info("-" * 70)
270
+ success("✔ Read-only operations only")
271
+ success("✔ No resource creation, modification, or deletion")
272
+ success("✔ Only Describe / List / Get APIs invoked")
273
+ success("✔ Enforced by CI safety regression tests")
274
+
275
+ success(" Suitable for production CI/CD pipelines")
276
+ else:
277
+ if method_id == "profile":
278
+ info("CI/CD Ready: NO (Local development only)")
279
+ info("AWS CLI profiles are not available in CI/CD")
280
+ else:
281
+ warn("CI/CD Ready: NO ⚠️")
282
+ warn("Not recommended for automated pipelines")
283
+
284
+ # Compliance notes
285
+ info("")
286
+ if metadata.get("security_grade") in ("excellent", "good"):
287
+ success("Compliance: SOC2/ISO27001 Compatible ✅")
288
+ elif metadata.get("security_grade") == "acceptable":
289
+ info("Compliance: Acceptable for development environments")
290
+ else:
291
+ warn("Compliance: May not meet enterprise security requirements ⚠️")
292
+
293
+ # Step 3: Identity verification
294
+ info("")
295
+ info("👤 Step 3: Identity Verification")
296
+ info("-" * 70)
297
+
298
+ try:
299
+ sts = session.client("sts")
300
+ identity = sts.get_caller_identity()
301
+ except Exception as e:
302
+ fail(f"AWS identity verification failed: {e}")
303
+
304
+ arn = identity["Arn"]
305
+ account = identity["Account"]
306
+ user_id = identity["UserId"]
307
+
308
+ success(f"Account ID: {account}")
309
+ success(f"User ID: {user_id}")
310
+ success(f"ARN: {arn}")
311
+
312
+ # Parse ARN for additional context
313
+ if ":assumed-role/" in arn:
314
+ role_name = arn.split("/")[-2]
315
+ session_name = arn.split("/")[-1]
316
+ info(f" Role Name: {role_name}")
317
+ info(f" Session Name: {session_name}")
318
+
319
+ # Check if it's OIDC-based role
320
+ if method_id == "oidc":
321
+ success(" ✓ OIDC-based assumed role (recommended)")
322
+
323
+ elif ":user/" in arn:
324
+ user_name = arn.split("/")[-1]
325
+ info(f" IAM User: {user_name}")
326
+
327
+ if method_id == "static_keys":
328
+ warn(" ⚠ Using IAM user credentials (not recommended for CI/CD)")
329
+
330
+ # Region scope clarification
331
+ info("")
332
+ info("🌍 Region Scope")
333
+ info("-" * 70)
334
+ info(f"Active Region: {region}")
335
+ info("Doctor validates permissions for the active region only")
336
+ info("Multi-region scanning (future) will require region enumeration permissions")
337
+
338
+ # Step 4: Permission validation
339
+ info("")
340
+ info("🔒 Step 4: Read-Only Permission Validation")
341
+ info("-" * 70)
342
+
343
+ permissions_tested = []
344
+ permissions_failed = []
345
+
346
+ try:
347
+ ec2 = session.client("ec2", region_name=region)
348
+
349
+ # Test EC2 permissions
350
+ try:
351
+ ec2.describe_volumes(MaxResults=6)
352
+ permissions_tested.append("ec2:DescribeVolumes")
353
+ success("✓ ec2:DescribeVolumes")
354
+ except Exception as e:
355
+ permissions_failed.append(("ec2:DescribeVolumes", str(e)))
356
+ warn(f"✗ ec2:DescribeVolumes - {e}")
357
+
358
+ try:
359
+ ec2.describe_snapshots(OwnerIds=["self"], MaxResults=5)
360
+ permissions_tested.append("ec2:DescribeSnapshots")
361
+ success("✓ ec2:DescribeSnapshots")
362
+ except Exception as e:
363
+ permissions_failed.append(("ec2:DescribeSnapshots", str(e)))
364
+ warn(f"✗ ec2:DescribeSnapshots - {e}")
365
+
366
+ try:
367
+ ec2.describe_regions()
368
+ permissions_tested.append("ec2:DescribeRegions")
369
+ success("✓ ec2:DescribeRegions")
370
+ except Exception as e:
371
+ permissions_failed.append(("ec2:DescribeRegions", str(e)))
372
+ warn(f"✗ ec2:DescribeRegions - {e}")
373
+
374
+ # Test CloudWatch Logs permissions
375
+ try:
376
+ logs = session.client("logs", region_name=region)
377
+ logs.describe_log_groups(limit=1)
378
+ permissions_tested.append("logs:DescribeLogGroups")
379
+ success("✓ logs:DescribeLogGroups")
380
+ except Exception as e:
381
+ permissions_failed.append(("logs:DescribeLogGroups", str(e)))
382
+ warn(f"✗ logs:DescribeLogGroups - {e}")
383
+
384
+ # Test S3 permissions
385
+ try:
386
+ s3 = session.client("s3")
387
+ s3.list_buckets()
388
+ permissions_tested.append("s3:ListAllMyBuckets")
389
+ success("✓ s3:ListAllMyBuckets")
390
+ except Exception as e:
391
+ permissions_failed.append(("s3:ListAllMyBuckets", str(e)))
392
+ warn(f"✗ s3:ListAllMyBuckets - {e}")
393
+
394
+ except Exception:
395
+ fail("CleanCloud cannot run safely with missing read-only permissions")
396
+
397
+ # Summary
398
+ info("")
399
+ info("=" * 70)
400
+ info("VALIDATION SUMMARY")
401
+ info("=" * 70)
402
+
403
+ total_permissions = len(permissions_tested) + len(permissions_failed)
404
+ success_count = len(permissions_tested)
405
+
406
+ info(f"Authentication: {description}")
407
+ info(f"Security Grade: {security_grade.upper()}")
408
+ info(f"Permissions Tested: {success_count}/{total_permissions} passed")
409
+
410
+ if permissions_failed:
411
+ info("")
412
+ warn("Missing Permissions:")
413
+ for perm, error in permissions_failed:
414
+ warn(f" - {perm}")
415
+ info("")
416
+ info("To fix: Attach CleanCloudReadOnly policy to your IAM role/user")
417
+ info("See: https://docs.cleancloud.io/aws#iam-policy")
418
+ fail("AWS permission validation failed")
419
+
420
+ info("")
421
+ success("🎉 AWS ENVIRONMENT READY FOR CLEANCLOUD")
422
+ info("=" * 70)
423
+ info("")