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.
- cleancloud/__init__.py +0 -0
- cleancloud/cleancloud.yaml +11 -0
- cleancloud/cli.py +22 -0
- cleancloud/config/schema.py +65 -0
- cleancloud/core/__init__.py +0 -0
- cleancloud/core/confidence.py +14 -0
- cleancloud/core/evidence.py +9 -0
- cleancloud/core/finding.py +44 -0
- cleancloud/core/risk.py +7 -0
- cleancloud/doctor/__init__.py +3 -0
- cleancloud/doctor/aws.py +423 -0
- cleancloud/doctor/azure.py +270 -0
- cleancloud/doctor/command.py +45 -0
- cleancloud/doctor/common.py +19 -0
- cleancloud/doctor/runner.py +128 -0
- cleancloud/filtering/tags.py +57 -0
- cleancloud/output/csv.py +31 -0
- cleancloud/output/feedback.py +29 -0
- cleancloud/output/human.py +32 -0
- cleancloud/output/json.py +23 -0
- cleancloud/output/progress.py +3 -0
- cleancloud/output/summary.py +92 -0
- cleancloud/policy/__init__.py +0 -0
- cleancloud/policy/exit_policy.py +56 -0
- cleancloud/providers/__init__.py +0 -0
- cleancloud/providers/aws/__init__.py +0 -0
- cleancloud/providers/aws/rules/__init__.py +0 -0
- cleancloud/providers/aws/rules/cloudwatch_inactive.py +70 -0
- cleancloud/providers/aws/rules/ebs_snapshot_old.py +75 -0
- cleancloud/providers/aws/rules/ebs_unattached.py +79 -0
- cleancloud/providers/aws/rules/untagged_resources.py +145 -0
- cleancloud/providers/aws/scan.py +255 -0
- cleancloud/providers/aws/session.py +9 -0
- cleancloud/providers/aws/validate.py +86 -0
- cleancloud/providers/azure/__init__.py +0 -0
- cleancloud/providers/azure/rules/__init__.py +0 -0
- cleancloud/providers/azure/rules/ebs_snapshots_old.py +97 -0
- cleancloud/providers/azure/rules/public_ip_unused.py +79 -0
- cleancloud/providers/azure/rules/unattached_managed_disks.py +103 -0
- cleancloud/providers/azure/rules/untagged_resources.py +142 -0
- cleancloud/providers/azure/scan.py +217 -0
- cleancloud/providers/azure/session.py +77 -0
- cleancloud/providers/azure/validate.py +113 -0
- cleancloud/safety/__init__.py +0 -0
- cleancloud/safety/aws/__init__.py +0 -0
- cleancloud/safety/aws/allowlist.py +18 -0
- cleancloud/safety/azure/__init__.py +0 -0
- cleancloud/safety/azure/allowlist.py +13 -0
- cleancloud/scan/__init__.py +0 -0
- cleancloud/scan/command.py +257 -0
- cleancloud-1.0.1.dist-info/METADATA +725 -0
- cleancloud-1.0.1.dist-info/RECORD +56 -0
- cleancloud-1.0.1.dist-info/WHEEL +5 -0
- cleancloud-1.0.1.dist-info/entry_points.txt +2 -0
- cleancloud-1.0.1.dist-info/licenses/LICENSE +21 -0
- cleancloud-1.0.1.dist-info/top_level.txt +1 -0
cleancloud/__init__.py
ADDED
|
File without changes
|
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,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
|
+
}
|
cleancloud/core/risk.py
ADDED
cleancloud/doctor/aws.py
ADDED
|
@@ -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("")
|