cloudglass 0.2.0__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.
cloudglass/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
cloudglass/cli.py ADDED
@@ -0,0 +1,358 @@
1
+ """CloudGlass CLI — entrypoint for the cloud security scanner."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import boto3
11
+ import click
12
+ from botocore.config import Config
13
+ from botocore.exceptions import ClientError, NoCredentialsError
14
+ from rich.columns import Columns
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from .collectors.cloudtrail import collect_cloudtrail
21
+ from .collectors.ec2 import collect_security_groups
22
+ from .collectors.iam import collect_iam_users, collect_root_account
23
+ from .collectors.kms import collect_kms_keys
24
+ from .collectors.lambda_ import collect_lambda_functions
25
+ from .collectors.rds import collect_rds_instances
26
+ from .collectors.s3 import collect_s3_buckets
27
+ from .engine import Finding, load_yaml_rules, run_rules
28
+ from .models import Resource
29
+ from .rules.aws_rules import RULES
30
+
31
+ console = Console(stderr=False)
32
+
33
+ SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
34
+ SEVERITY_COLOR = {
35
+ "CRITICAL": "bold red",
36
+ "HIGH": "red",
37
+ "MEDIUM": "yellow",
38
+ "LOW": "cyan",
39
+ }
40
+ FAIL_ON_CHOICES = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "NONE"]
41
+
42
+ BANNER = r"""
43
+ ██████╗██╗ ██████╗ ██╗ ██╗██████╗ ██████╗ ██╗ █████╗ ███████╗███████╗
44
+ ██╔════╝██║ ██╔═══██╗██║ ██║██╔══██╗██╔════╝ ██║ ██╔══██╗██╔════╝██╔════╝
45
+ ██║ ██║ ██║ ██║██║ ██║██║ ██║██║ ███╗██║ ███████║███████╗███████╗
46
+ ██║ ██║ ██║ ██║██║ ██║██║ ██║██║ ██║██║ ██╔══██║╚════██║╚════██║
47
+ ╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝╚██████╔╝███████╗██║ ██║███████║███████║
48
+ ╚═════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝"""
49
+
50
+
51
+ def _boto_config() -> Config:
52
+ """Return a botocore Config with adaptive retries for throttle resilience."""
53
+ return Config(
54
+ retries={"mode": "adaptive", "max_attempts": 6},
55
+ connect_timeout=10,
56
+ read_timeout=30,
57
+ )
58
+
59
+
60
+ def _print_banner() -> None:
61
+ """Print the CloudGlass ASCII banner with author credit."""
62
+ banner_text = Text(BANNER, style="bold cyan")
63
+ credit = Text("by shubham.cybersky", style="dim white")
64
+ console.print(banner_text)
65
+ # Right-align credit to match banner width (~88 chars)
66
+ console.print(credit, justify="right")
67
+ console.print()
68
+
69
+
70
+ def _get_all_regions(session: boto3.Session) -> list[str]:
71
+ """Return all enabled AWS regions."""
72
+ ec2 = session.client("ec2", region_name="us-east-1", config=_boto_config())
73
+ try:
74
+ resp = ec2.describe_regions(Filters=[{"Name": "opt-in-status", "Values": ["opt-in-not-required", "opted-in"]}])
75
+ return [r["RegionName"] for r in resp["Regions"]]
76
+ except ClientError:
77
+ return ["us-east-1"]
78
+
79
+
80
+ def _collect_all(
81
+ session: boto3.Session,
82
+ regions: list[str],
83
+ ) -> list[Resource]:
84
+ """
85
+ Concurrently collect resources from all collectors across all regions.
86
+ Global services (S3, IAM) are collected once; regional services are collected per-region.
87
+ """
88
+ resources: list[Resource] = []
89
+ futures = []
90
+
91
+ with ThreadPoolExecutor(max_workers=min(32, len(regions) * 4 + 2)) as pool:
92
+ # Global (region-independent)
93
+ futures.append(pool.submit(collect_s3_buckets, session))
94
+ futures.append(pool.submit(collect_iam_users, session))
95
+ futures.append(pool.submit(collect_root_account, session))
96
+
97
+ # Regional
98
+ for region in regions:
99
+ futures.append(pool.submit(collect_security_groups, session, region))
100
+ futures.append(pool.submit(collect_rds_instances, session, region))
101
+ futures.append(pool.submit(collect_cloudtrail, session, region))
102
+ futures.append(pool.submit(collect_kms_keys, session, region))
103
+ futures.append(pool.submit(collect_lambda_functions, session, region))
104
+
105
+ for future in as_completed(futures):
106
+ try:
107
+ result = future.result()
108
+ resources.extend(result)
109
+ except Exception as exc:
110
+ console.print(f"[red]Collector error: {exc}[/red]", stderr=True)
111
+
112
+ return resources
113
+
114
+
115
+ def _build_table(findings: list[Finding], show_compliance: bool) -> Table:
116
+ """Build a Rich table of findings."""
117
+ table = Table(
118
+ title=f"CloudGlass Findings ({len(findings)})",
119
+ show_lines=False,
120
+ highlight=True,
121
+ )
122
+ table.add_column("Severity", no_wrap=True)
123
+ table.add_column("Rule", no_wrap=True)
124
+ table.add_column("Title")
125
+ table.add_column("Resource")
126
+ table.add_column("Region", no_wrap=True)
127
+ if show_compliance:
128
+ table.add_column("Compliance*")
129
+ table.add_column("Remediation")
130
+
131
+ for f in findings:
132
+ color = SEVERITY_COLOR.get(f.severity, "white")
133
+ row = [
134
+ f"[{color}]{f.severity}[/{color}]",
135
+ f.rule_id,
136
+ f.title,
137
+ f.resource_name,
138
+ f.region or "global",
139
+ ]
140
+ if show_compliance:
141
+ row.append(", ".join(f.compliance) if f.compliance else "")
142
+ row.append(f.remediation)
143
+ table.add_row(*row)
144
+ return table
145
+
146
+
147
+ def _print_scorecard(findings: list[Finding]) -> None:
148
+ """Print a per-severity summary scorecard."""
149
+ counts = {sev: 0 for sev in SEVERITY_ORDER}
150
+ for f in findings:
151
+ if f.severity in counts:
152
+ counts[f.severity] += 1
153
+
154
+ cells = []
155
+ for sev in SEVERITY_ORDER:
156
+ color = SEVERITY_COLOR[sev]
157
+ count = counts[sev]
158
+ cells.append(Panel(f"[{color}][bold]{count}[/bold][/{color}]", title=f"[{color}]{sev}[/{color}]", width=16))
159
+ console.print()
160
+ console.print(Columns(cells))
161
+ console.print(" [dim]* Compliance tags are indicative starting points, not certified mappings.[/dim]\n")
162
+
163
+
164
+ def _findings_to_json(findings: list[Finding]) -> str:
165
+ """Serialize findings to JSON."""
166
+ return json.dumps(
167
+ [
168
+ {
169
+ "rule_id": f.rule_id,
170
+ "title": f.title,
171
+ "severity": f.severity,
172
+ "resource_id": f.resource_id,
173
+ "resource_name": f.resource_name,
174
+ "region": f.region,
175
+ "remediation": f.remediation,
176
+ "compliance": f.compliance,
177
+ }
178
+ for f in findings
179
+ ],
180
+ indent=2,
181
+ )
182
+
183
+
184
+ def _findings_to_sarif(findings: list[Finding]) -> str:
185
+ """Serialize findings to SARIF 2.1.0 format."""
186
+ rules = {}
187
+ for f in findings:
188
+ if f.rule_id not in rules:
189
+ rules[f.rule_id] = {
190
+ "id": f.rule_id,
191
+ "name": f.title,
192
+ "shortDescription": {"text": f.title},
193
+ "help": {"text": f.remediation},
194
+ "properties": {"tags": f.compliance},
195
+ }
196
+
197
+ severity_map = {"CRITICAL": "error", "HIGH": "error", "MEDIUM": "warning", "LOW": "note"}
198
+
199
+ results = [
200
+ {
201
+ "ruleId": f.rule_id,
202
+ "level": severity_map.get(f.severity, "warning"),
203
+ "message": {"text": f"{f.title} — {f.resource_name} ({f.region or 'global'})"},
204
+ "locations": [
205
+ {
206
+ "physicalLocation": {
207
+ "artifactLocation": {"uri": f"aws://{f.resource_id}"},
208
+ }
209
+ }
210
+ ],
211
+ }
212
+ for f in findings
213
+ ]
214
+
215
+ sarif = {
216
+ "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
217
+ "version": "2.1.0",
218
+ "runs": [
219
+ {
220
+ "tool": {
221
+ "driver": {
222
+ "name": "CloudGlass",
223
+ "version": "0.2.0",
224
+ "rules": list(rules.values()),
225
+ }
226
+ },
227
+ "results": results,
228
+ }
229
+ ],
230
+ }
231
+ return json.dumps(sarif, indent=2)
232
+
233
+
234
+ def _exit_code(findings: list[Finding], fail_on: str) -> int:
235
+ """Compute exit code: 1 if any finding meets or exceeds the fail_on threshold, else 0."""
236
+ if fail_on == "NONE":
237
+ return 0
238
+ threshold_index = SEVERITY_ORDER.index(fail_on)
239
+ for f in findings:
240
+ try:
241
+ if SEVERITY_ORDER.index(f.severity) <= threshold_index:
242
+ return 1
243
+ except ValueError:
244
+ pass
245
+ return 0
246
+
247
+
248
+ @click.group(epilog="""
249
+ Examples:
250
+ # Scan default region using default credentials
251
+ cloudglass scan
252
+
253
+ # Scan a specific profile and region
254
+ cloudglass scan --profile prod --region us-west-2
255
+
256
+ # Scan all regions and output to JSON
257
+ cloudglass scan --all-regions --output json
258
+
259
+ # Fail pipeline on CRITICAL findings only
260
+ cloudglass scan --fail-on CRITICAL
261
+ """)
262
+ @click.version_option(version="0.2.0", message="%(prog)s %(version)s")
263
+ def main():
264
+ """CloudGlass — see through your cloud's security posture."""
265
+
266
+
267
+ @main.command()
268
+ @click.option("--profile", default=None, help="AWS named profile to use.")
269
+ @click.option("--region", default="us-east-1", show_default=True, help="Primary AWS region for regional services.")
270
+ @click.option("--all-regions", is_flag=True, default=False, help="Scan all enabled AWS regions.")
271
+ @click.option("--output", "output_fmt", default="table", show_default=True, type=click.Choice(["table", "json", "sarif"], case_sensitive=False), help="Output format.")
272
+ @click.option("--fail-on", default="HIGH", show_default=True, type=click.Choice(FAIL_ON_CHOICES, case_sensitive=False), help="Exit 1 if any finding at this severity or above.")
273
+ @click.option("--severity", default=None, help="Comma-separated severity filter, e.g. HIGH,CRITICAL.")
274
+ @click.option("--service", default=None, help="Comma-separated service filter, e.g. s3,iam,rds.")
275
+ @click.option("--rule-id", default=None, help="Comma-separated rule ID filter, e.g. CG-S3-001.")
276
+ @click.option("--rules-file", default=None, type=click.Path(exists=True, path_type=Path), help="Path to a YAML file with custom rules.")
277
+ @click.option("--compliance", is_flag=True, default=False, help="Show compliance framework tags in table output.")
278
+ def scan(
279
+ profile: Optional[str],
280
+ region: str,
281
+ all_regions: bool,
282
+ output_fmt: str,
283
+ fail_on: str,
284
+ severity: Optional[str],
285
+ service: Optional[str],
286
+ rule_id: Optional[str],
287
+ rules_file: Optional[Path],
288
+ compliance: bool,
289
+ ) -> None:
290
+ """Scan an AWS account for security misconfigurations."""
291
+ _print_banner()
292
+
293
+ try:
294
+ session = boto3.Session(profile_name=profile, region_name=region)
295
+ except Exception as e:
296
+ console.print(f"[bold red]Failed to create AWS session:[/bold red] {e}")
297
+ sys.exit(2)
298
+
299
+ # Build rule list (built-ins + optional custom YAML rules)
300
+ rules = list(RULES)
301
+ if rules_file:
302
+ try:
303
+ custom = load_yaml_rules(rules_file)
304
+ rules.extend(custom)
305
+ console.print(f"Loaded [bold]{len(custom)}[/bold] custom rule(s) from [cyan]{rules_file}[/cyan].\n")
306
+ except Exception as e:
307
+ console.print(f"[bold red]Failed to load rules file:[/bold red] {e}")
308
+ sys.exit(2)
309
+
310
+ # Determine regions to scan
311
+ regions = _get_all_regions(session) if all_regions else [region]
312
+ region_label = "all regions" if all_regions else region
313
+ console.print(f"[bold]CloudGlass[/bold] — scanning AWS account across [cyan]{region_label}[/cyan]...\n")
314
+
315
+ # Parse filters
316
+ severity_filter = [s.strip().upper() for s in severity.split(",")] if severity else None
317
+ service_filter = [s.strip().lower() for s in service.split(",")] if service else None
318
+ rule_id_filter = [r.strip().upper() for r in rule_id.split(",")] if rule_id else None
319
+
320
+ # Collect (concurrent)
321
+ with console.status("Collecting resources (parallel)..."):
322
+ resources = _collect_all(session, regions)
323
+
324
+ console.print(f"Collected [bold]{len(resources)}[/bold] resources across [bold]{len(regions)}[/bold] region(s).\n")
325
+
326
+ # Run rules
327
+ findings = run_rules(
328
+ resources,
329
+ rules=rules,
330
+ severity_filter=severity_filter,
331
+ service_filter=service_filter,
332
+ rule_id_filter=rule_id_filter,
333
+ )
334
+
335
+ # Output
336
+ if output_fmt == "json":
337
+ print(_findings_to_json(findings))
338
+ elif output_fmt == "sarif":
339
+ print(_findings_to_sarif(findings))
340
+ else:
341
+ # Table
342
+ if not findings:
343
+ console.print("[bold green]✓ No findings. Nice.[/bold green]")
344
+ _print_scorecard(findings)
345
+ sys.exit(0)
346
+
347
+ findings_sorted = sorted(findings, key=lambda f: SEVERITY_ORDER.index(f.severity))
348
+ console.print(_build_table(findings_sorted, show_compliance=compliance))
349
+ _print_scorecard(findings_sorted)
350
+ if compliance:
351
+ console.print(" [dim italic]* Compliance tags are indicative starting points, not certified mappings.[/dim italic]\n")
352
+
353
+ code = _exit_code(findings, fail_on.upper())
354
+ sys.exit(code)
355
+
356
+
357
+ if __name__ == "__main__":
358
+ main()
File without changes
@@ -0,0 +1,47 @@
1
+ """CloudTrail collector — checks trail status per region."""
2
+ import boto3
3
+ from botocore.exceptions import ClientError
4
+
5
+ from ..models import Resource
6
+
7
+
8
+ def collect_cloudtrail(session: boto3.Session, region: str) -> list[Resource]:
9
+ """
10
+ Returns a single synthetic 'cloudtrail_account' resource per region
11
+ capturing whether a trail is enabled and whether a multi-region trail exists.
12
+ """
13
+ client = session.client("cloudtrail", region_name=region)
14
+
15
+ trail_enabled = False
16
+ multi_region = False
17
+
18
+ try:
19
+ trails = client.describe_trails(includeShadowTrails=True).get("trailList", [])
20
+ for trail in trails:
21
+ status_resp = client.get_trail_status(Name=trail["TrailARN"])
22
+ if status_resp.get("IsLogging"):
23
+ trail_enabled = True
24
+ if trail.get("IsMultiRegionTrail"):
25
+ multi_region = True
26
+ except ClientError as e:
27
+ code = e.response["Error"]["Code"]
28
+ if code in ("AccessDenied", "AccessDeniedException"):
29
+ print(f"[cloudglass] AccessDenied: cannot describe CloudTrail trails in {region}")
30
+ return []
31
+ else:
32
+ print(f"[cloudglass] Failed to describe CloudTrail in {region}: {e}")
33
+ return []
34
+
35
+ return [
36
+ Resource(
37
+ id=f"cloudtrail-{region}",
38
+ type="cloudtrail_account",
39
+ provider="aws",
40
+ name=f"cloudtrail-{region}",
41
+ region=region,
42
+ metadata={
43
+ "trail_enabled": trail_enabled,
44
+ "multi_region": multi_region,
45
+ },
46
+ )
47
+ ]
@@ -0,0 +1,51 @@
1
+ """EC2 collector — gathers security groups and normalizes them."""
2
+ import boto3
3
+ from botocore.exceptions import ClientError
4
+
5
+ from ..models import Resource
6
+
7
+
8
+ def collect_security_groups(session: boto3.Session, region: str) -> list[Resource]:
9
+ """Collect all EC2 security groups in the given region."""
10
+ ec2 = session.client("ec2", region_name=region)
11
+ resources: list[Resource] = []
12
+
13
+ try:
14
+ paginator = ec2.get_paginator("describe_security_groups")
15
+ for page in paginator.paginate():
16
+ for sg in page["SecurityGroups"]:
17
+ open_to_world = False
18
+ open_ports: set[str] = set()
19
+
20
+ for perm in sg.get("IpPermissions", []):
21
+ from_port = perm.get("FromPort")
22
+ to_port = perm.get("ToPort")
23
+ for ip_range in perm.get("IpRanges", []):
24
+ if ip_range.get("CidrIp") == "0.0.0.0/0":
25
+ open_to_world = True
26
+ open_ports.add("ALL" if from_port is None else f"{from_port}-{to_port}")
27
+ for ip_range in perm.get("Ipv6Ranges", []):
28
+ if ip_range.get("CidrIpv6") == "::/0":
29
+ open_to_world = True
30
+ open_ports.add("ALL" if from_port is None else f"{from_port}-{to_port}")
31
+
32
+ resources.append(
33
+ Resource(
34
+ id=sg["GroupId"],
35
+ type="security_group",
36
+ provider="aws",
37
+ name=sg.get("GroupName", sg["GroupId"]),
38
+ region=region,
39
+ is_public=open_to_world,
40
+ metadata={"open_ports": sorted(open_ports)},
41
+ raw=sg,
42
+ )
43
+ )
44
+ except ClientError as e:
45
+ code = e.response["Error"]["Code"]
46
+ if code in ("AccessDenied", "AccessDeniedException"):
47
+ print(f"[cloudglass] AccessDenied: cannot describe security groups in {region}")
48
+ else:
49
+ print(f"[cloudglass] Failed to describe security groups in {region}: {e}")
50
+
51
+ return resources
@@ -0,0 +1,72 @@
1
+ """IAM collector — gathers IAM users and root account posture."""
2
+ import boto3
3
+ from botocore.exceptions import ClientError
4
+
5
+ from ..models import Resource
6
+
7
+
8
+ def collect_iam_users(session: boto3.Session) -> list[Resource]:
9
+ """Collect all IAM users and their MFA status."""
10
+ iam = session.client("iam")
11
+ resources: list[Resource] = []
12
+
13
+ try:
14
+ paginator = iam.get_paginator("list_users")
15
+ for page in paginator.paginate():
16
+ for user in page["Users"]:
17
+ username = user["UserName"]
18
+ try:
19
+ mfa_devices = iam.list_mfa_devices(UserName=username).get("MFADevices", [])
20
+ mfa_enabled = len(mfa_devices) > 0
21
+ except ClientError:
22
+ mfa_enabled = None
23
+
24
+ resources.append(
25
+ Resource(
26
+ id=user["Arn"],
27
+ type="iam_user",
28
+ provider="aws",
29
+ name=username,
30
+ mfa_enabled=mfa_enabled,
31
+ raw=user,
32
+ )
33
+ )
34
+ except ClientError as e:
35
+ code = e.response["Error"]["Code"]
36
+ if code in ("AccessDenied", "AccessDeniedException"):
37
+ print("[cloudglass] AccessDenied: cannot list IAM users")
38
+ else:
39
+ print(f"[cloudglass] Failed to list IAM users: {e}")
40
+
41
+ return resources
42
+
43
+
44
+ def collect_root_account(session: boto3.Session) -> list[Resource]:
45
+ """Collect root account security posture from IAM account summary."""
46
+ iam = session.client("iam")
47
+ resources: list[Resource] = []
48
+
49
+ try:
50
+ summary = iam.get_account_summary()["SummaryMap"]
51
+ root_access_keys = summary.get("AccountAccessKeysPresent", 0) > 0
52
+ root_mfa = summary.get("AccountMFAEnabled", 0) == 1
53
+
54
+ resources.append(
55
+ Resource(
56
+ id="root-account",
57
+ type="iam_account",
58
+ provider="aws",
59
+ name="root",
60
+ mfa_enabled=root_mfa,
61
+ metadata={"root_access_keys_present": root_access_keys},
62
+ raw=summary,
63
+ )
64
+ )
65
+ except ClientError as e:
66
+ code = e.response["Error"]["Code"]
67
+ if code in ("AccessDenied", "AccessDeniedException"):
68
+ print("[cloudglass] AccessDenied: cannot get account summary")
69
+ else:
70
+ print(f"[cloudglass] Failed to get account summary: {e}")
71
+
72
+ return resources
@@ -0,0 +1,64 @@
1
+ """KMS collector — gathers customer-managed keys and checks rotation."""
2
+ import boto3
3
+ from botocore.exceptions import ClientError
4
+
5
+ from ..models import Resource
6
+
7
+
8
+ def collect_kms_keys(session: boto3.Session, region: str) -> list[Resource]:
9
+ """Collect all customer-managed KMS keys in the given region."""
10
+ client = session.client("kms", region_name=region)
11
+ resources: list[Resource] = []
12
+
13
+ try:
14
+ paginator = client.get_paginator("list_keys")
15
+ for page in paginator.paginate():
16
+ for key_meta in page["Keys"]:
17
+ key_id = key_meta["KeyId"]
18
+ key_arn = key_meta["KeyArn"]
19
+
20
+ # Skip AWS-managed keys — only audit customer-managed keys
21
+ try:
22
+ key_desc = client.describe_key(KeyId=key_id)["KeyMetadata"]
23
+ except ClientError:
24
+ continue
25
+
26
+ if key_desc.get("KeyManager") != "CUSTOMER":
27
+ continue
28
+ if key_desc.get("KeyState") not in ("Enabled",):
29
+ continue
30
+ if key_desc.get("KeySpec") == "SYMMETRIC_DEFAULT" and key_desc.get("MultiRegion") is False:
31
+ # Only symmetric single-region keys support auto-rotation
32
+ pass
33
+
34
+ rotation_enabled = False
35
+ try:
36
+ rot = client.get_key_rotation_status(KeyId=key_id)
37
+ rotation_enabled = rot.get("KeyRotationEnabled", False)
38
+ except ClientError:
39
+ rotation_enabled = False
40
+
41
+ resources.append(
42
+ Resource(
43
+ id=key_arn,
44
+ type="kms_key",
45
+ provider="aws",
46
+ name=key_desc.get("Description") or key_id,
47
+ region=region,
48
+ metadata={
49
+ "rotation_enabled": rotation_enabled,
50
+ "key_state": key_desc.get("KeyState"),
51
+ "key_manager": key_desc.get("KeyManager"),
52
+ "key_spec": key_desc.get("KeySpec"),
53
+ },
54
+ raw=key_desc,
55
+ )
56
+ )
57
+ except ClientError as e:
58
+ code = e.response["Error"]["Code"]
59
+ if code in ("AccessDenied", "AccessDeniedException"):
60
+ print(f"[cloudglass] AccessDenied: cannot list KMS keys in {region}")
61
+ else:
62
+ print(f"[cloudglass] Failed to list KMS keys in {region}: {e}")
63
+
64
+ return resources
@@ -0,0 +1,54 @@
1
+ """Lambda collector — gathers functions and checks function URL auth."""
2
+ import boto3
3
+ from botocore.exceptions import ClientError
4
+
5
+ from ..models import Resource
6
+
7
+
8
+ def collect_lambda_functions(session: boto3.Session, region: str) -> list[Resource]:
9
+ """Collect all Lambda functions in the given region."""
10
+ client = session.client("lambda", region_name=region)
11
+ resources: list[Resource] = []
12
+
13
+ try:
14
+ paginator = client.get_paginator("list_functions")
15
+ for page in paginator.paginate():
16
+ for fn in page["Functions"]:
17
+ fn_name = fn["FunctionName"]
18
+ fn_arn = fn["FunctionArn"]
19
+
20
+ # Check for function URLs with no auth
21
+ function_url_no_auth = False
22
+ try:
23
+ url_configs = client.list_function_url_configs(FunctionName=fn_name)
24
+ for cfg in url_configs.get("FunctionUrlConfigs", []):
25
+ if cfg.get("AuthType") == "NONE":
26
+ function_url_no_auth = True
27
+ break
28
+ except ClientError as e:
29
+ if e.response["Error"]["Code"] != "ResourceNotFoundException":
30
+ pass # no URL configured is fine
31
+
32
+ resources.append(
33
+ Resource(
34
+ id=fn_arn,
35
+ type="lambda_function",
36
+ provider="aws",
37
+ name=fn_name,
38
+ region=region,
39
+ metadata={
40
+ "runtime": fn.get("Runtime"),
41
+ "function_url_no_auth": function_url_no_auth,
42
+ "package_type": fn.get("PackageType"),
43
+ },
44
+ raw=fn,
45
+ )
46
+ )
47
+ except ClientError as e:
48
+ code = e.response["Error"]["Code"]
49
+ if code in ("AccessDenied", "AccessDeniedException"):
50
+ print(f"[cloudglass] AccessDenied: cannot list Lambda functions in {region}")
51
+ else:
52
+ print(f"[cloudglass] Failed to list Lambda functions in {region}: {e}")
53
+
54
+ return resources
@@ -0,0 +1,43 @@
1
+ """RDS collector — gathers DB instances and normalizes them."""
2
+ import boto3
3
+ from botocore.exceptions import ClientError
4
+
5
+ from ..models import Resource
6
+
7
+
8
+ def collect_rds_instances(session: boto3.Session, region: str) -> list[Resource]:
9
+ """Collect all RDS DB instances in the given region."""
10
+ client = session.client("rds", region_name=region)
11
+ resources: list[Resource] = []
12
+
13
+ try:
14
+ paginator = client.get_paginator("describe_db_instances")
15
+ for page in paginator.paginate():
16
+ for db in page["DBInstances"]:
17
+ resources.append(
18
+ Resource(
19
+ id=db["DBInstanceArn"],
20
+ type="rds_instance",
21
+ provider="aws",
22
+ name=db["DBInstanceIdentifier"],
23
+ region=region,
24
+ is_public=db.get("PubliclyAccessible", False),
25
+ encrypted=db.get("StorageEncrypted", False),
26
+ metadata={
27
+ "engine": db.get("Engine"),
28
+ "engine_version": db.get("EngineVersion"),
29
+ "instance_class": db.get("DBInstanceClass"),
30
+ "status": db.get("DBInstanceStatus"),
31
+ "multi_az": db.get("MultiAZ", False),
32
+ },
33
+ raw=db,
34
+ )
35
+ )
36
+ except ClientError as e:
37
+ code = e.response["Error"]["Code"]
38
+ if code in ("AccessDenied", "AccessDeniedException"):
39
+ print(f"[cloudglass] AccessDenied: cannot describe RDS instances in {region}")
40
+ else:
41
+ print(f"[cloudglass] Failed to describe RDS instances in {region}: {e}")
42
+
43
+ return resources
@@ -0,0 +1,78 @@
1
+ """S3 collector — gathers buckets and normalizes public access + encryption."""
2
+ import boto3
3
+ from botocore.exceptions import ClientError
4
+
5
+ from ..models import Resource
6
+
7
+
8
+ def collect_s3_buckets(session: boto3.Session) -> list[Resource]:
9
+ """Collect all S3 buckets in the account with public-access and encryption status."""
10
+ s3 = session.client("s3")
11
+ resources: list[Resource] = []
12
+
13
+ try:
14
+ buckets = s3.list_buckets().get("Buckets", [])
15
+ except ClientError as e:
16
+ code = e.response["Error"]["Code"]
17
+ if code in ("AccessDenied", "AccessDeniedException"):
18
+ print("[cloudglass] AccessDenied: cannot list S3 buckets")
19
+ else:
20
+ print(f"[cloudglass] Failed to list S3 buckets: {e}")
21
+ return resources
22
+
23
+ for b in buckets:
24
+ name = b["Name"]
25
+ is_public = False
26
+ encrypted = False
27
+
28
+ # Block Public Access config
29
+ fully_blocked = False
30
+ try:
31
+ pab = s3.get_public_access_block(Bucket=name)["PublicAccessBlockConfiguration"]
32
+ fully_blocked = all(
33
+ pab.get(k, False)
34
+ for k in ["BlockPublicAcls", "IgnorePublicAcls", "BlockPublicPolicy", "RestrictPublicBuckets"]
35
+ )
36
+ except ClientError:
37
+ pass
38
+
39
+ # ACL grants to AllUsers / AuthenticatedUsers
40
+ try:
41
+ acl = s3.get_bucket_acl(Bucket=name)
42
+ for grant in acl.get("Grants", []):
43
+ uri = grant.get("Grantee", {}).get("URI", "")
44
+ if "AllUsers" in uri or "AuthenticatedUsers" in uri:
45
+ is_public = True
46
+ except ClientError:
47
+ pass
48
+
49
+ # Bucket policy public status
50
+ try:
51
+ status = s3.get_bucket_policy_status(Bucket=name)
52
+ if status.get("PolicyStatus", {}).get("IsPublic"):
53
+ is_public = True
54
+ except ClientError:
55
+ pass
56
+
57
+ if fully_blocked:
58
+ is_public = False
59
+
60
+ try:
61
+ s3.get_bucket_encryption(Bucket=name)
62
+ encrypted = True
63
+ except ClientError:
64
+ encrypted = False
65
+
66
+ resources.append(
67
+ Resource(
68
+ id=name,
69
+ type="storage_bucket",
70
+ provider="aws",
71
+ name=name,
72
+ is_public=is_public,
73
+ encrypted=encrypted,
74
+ raw=b,
75
+ )
76
+ )
77
+
78
+ return resources
cloudglass/engine.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Rule engine: matches collected resources against rules and produces findings.
3
+ Supports built-in Python rules and custom YAML rules.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import importlib
8
+ import importlib.util
9
+ import sys
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Optional
13
+
14
+ import yaml
15
+
16
+ from .models import Resource
17
+ from .rules.aws_rules import RULES, Rule
18
+
19
+
20
+ @dataclass
21
+ class Finding:
22
+ rule_id: str
23
+ title: str
24
+ severity: str
25
+ resource_id: str
26
+ resource_name: str
27
+ region: Optional[str]
28
+ remediation: str
29
+ compliance: list[str] = field(default_factory=list) # e.g. ["CIS 2.1", "SOC2 CC6"]
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # YAML custom rule loader
34
+ # ---------------------------------------------------------------------------
35
+
36
+ _CONDITION_OPS = {
37
+ "equals": lambda val, expected: val == expected,
38
+ "not_equals": lambda val, expected: val != expected,
39
+ "contains": lambda val, expected: expected in (val or ""),
40
+ "is_true": lambda val, _: val is True,
41
+ "is_false": lambda val, _: val is False,
42
+ "exists": lambda val, _: val is not None,
43
+ }
44
+
45
+
46
+ def _field_value(resource: Resource, field_path: str) -> Any:
47
+ """Retrieve a value from a Resource, supporting metadata.<key> dotted paths."""
48
+ if field_path.startswith("metadata."):
49
+ key = field_path[len("metadata."):]
50
+ return resource.metadata.get(key)
51
+ return getattr(resource, field_path, None)
52
+
53
+
54
+ def _yaml_rule_to_rule(data: dict) -> Rule:
55
+ """Convert a parsed YAML rule dict into a Rule dataclass."""
56
+ conditions = data.get("conditions", [])
57
+ compliance = data.get("compliance", [])
58
+
59
+ def check(resource: Resource) -> bool:
60
+ for cond in conditions:
61
+ field_path = cond["field"]
62
+ op = cond["op"]
63
+ expected = cond.get("value")
64
+ val = _field_value(resource, field_path)
65
+ op_fn = _CONDITION_OPS.get(op)
66
+ if op_fn is None:
67
+ raise ValueError(f"Unknown condition op: {op!r}")
68
+ if not op_fn(val, expected):
69
+ return False # all conditions must pass (AND logic)
70
+ return True
71
+
72
+ rule = Rule(
73
+ id=data["id"],
74
+ title=data["title"],
75
+ severity=data["severity"].upper(),
76
+ resource_type=data["resource_type"],
77
+ check=check,
78
+ remediation=data.get("remediation", ""),
79
+ compliance=compliance,
80
+ )
81
+ return rule
82
+
83
+
84
+ def load_yaml_rules(path: str | Path) -> list[Rule]:
85
+ """Load custom rules from a YAML file. Returns a list of Rule objects."""
86
+ path = Path(path)
87
+ with path.open() as fh:
88
+ docs = yaml.safe_load(fh)
89
+ if not isinstance(docs, list):
90
+ docs = [docs]
91
+ return [_yaml_rule_to_rule(d) for d in docs]
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Core engine
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def run_rules(
100
+ resources: list[Resource],
101
+ rules: list[Rule] | None = None,
102
+ severity_filter: Optional[list[str]] = None,
103
+ service_filter: Optional[list[str]] = None,
104
+ rule_id_filter: Optional[list[str]] = None,
105
+ ) -> list[Finding]:
106
+ """
107
+ Run all applicable rules against resources and return findings.
108
+
109
+ Filters (all optional, comma-separated strings that callers already split):
110
+ - severity_filter: only keep findings with matching severity
111
+ - service_filter: only run rules whose id prefix matches (e.g. 's3' → CG-S3-*)
112
+ - rule_id_filter: only run specific rule IDs
113
+ """
114
+ if rules is None:
115
+ rules = RULES
116
+
117
+ active_rules = rules
118
+ if service_filter:
119
+ svc_upper = [s.upper() for s in service_filter]
120
+ active_rules = [r for r in active_rules if any(f"CG-{s}-" in r.id for s in svc_upper)]
121
+ if rule_id_filter:
122
+ rid_upper = [r.upper() for r in rule_id_filter]
123
+ active_rules = [r for r in active_rules if r.id.upper() in rid_upper]
124
+
125
+ findings: list[Finding] = []
126
+
127
+ for resource in resources:
128
+ for rule in active_rules:
129
+ if rule.resource_type != resource.type:
130
+ continue
131
+ try:
132
+ failed = rule.check(resource)
133
+ except Exception:
134
+ continue
135
+ if failed:
136
+ findings.append(
137
+ Finding(
138
+ rule_id=rule.id,
139
+ title=rule.title,
140
+ severity=rule.severity,
141
+ resource_id=resource.id,
142
+ resource_name=resource.name,
143
+ region=resource.region,
144
+ remediation=rule.remediation,
145
+ compliance=getattr(rule, "compliance", []),
146
+ )
147
+ )
148
+
149
+ if severity_filter:
150
+ sev_upper = [s.upper() for s in severity_filter]
151
+ findings = [f for f in findings if f.severity in sev_upper]
152
+
153
+ return findings
cloudglass/models.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ Normalized resource model.
3
+
4
+ Every collector (AWS, and later Azure/GCP) converts provider-specific
5
+ API responses into this common shape. Rules are then written against
6
+ this shape instead of against raw provider data, so most rules work
7
+ across clouds without modification.
8
+ """
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Optional
11
+
12
+
13
+ @dataclass
14
+ class Resource:
15
+ id: str # unique identifier (ARN, resource ID, etc.)
16
+ type: str # e.g. "storage_bucket", "iam_user", "security_group"
17
+ provider: str # "aws", "azure", "gcp"
18
+ name: str
19
+ region: Optional[str] = None
20
+ is_public: Optional[bool] = None
21
+ encrypted: Optional[bool] = None
22
+ mfa_enabled: Optional[bool] = None
23
+ metadata: dict[str, Any] = field(default_factory=dict)
24
+ raw: dict[str, Any] = field(default_factory=dict) # original provider response, for debugging
25
+ tags: dict[str, str] = field(default_factory=dict) # AWS resource tags
File without changes
@@ -0,0 +1,222 @@
1
+ """
2
+ Built-in AWS rules.
3
+
4
+ Each Rule's `check` function returns True when the resource FAILS the
5
+ check (i.e. a finding should be raised), False when it passes.
6
+
7
+ Compliance tags are indicative starting points, NOT certified mappings.
8
+ """
9
+ from dataclasses import dataclass, field
10
+ from typing import Callable
11
+
12
+ from ..models import Resource
13
+
14
+
15
+ @dataclass
16
+ class Rule:
17
+ id: str
18
+ title: str
19
+ severity: str # LOW, MEDIUM, HIGH, CRITICAL
20
+ resource_type: str
21
+ check: Callable[[Resource], bool]
22
+ remediation: str
23
+ compliance: list[str] = field(default_factory=list)
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # S3
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _s3_public(r: Resource) -> bool:
31
+ return r.is_public is True
32
+
33
+
34
+ def _s3_unencrypted(r: Resource) -> bool:
35
+ return r.encrypted is False
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # IAM
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def _iam_user_no_mfa(r: Resource) -> bool:
43
+ return r.mfa_enabled is False
44
+
45
+
46
+ def _root_no_mfa(r: Resource) -> bool:
47
+ return r.mfa_enabled is False
48
+
49
+
50
+ def _root_access_keys(r: Resource) -> bool:
51
+ return r.metadata.get("root_access_keys_present") is True
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # EC2 / Security Groups
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def _sg_open_to_world(r: Resource) -> bool:
59
+ return r.is_public is True
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # RDS
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def _rds_publicly_accessible(r: Resource) -> bool:
67
+ return r.is_public is True
68
+
69
+
70
+ def _rds_unencrypted(r: Resource) -> bool:
71
+ return r.encrypted is False
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # CloudTrail
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def _cloudtrail_not_enabled(r: Resource) -> bool:
79
+ return r.metadata.get("trail_enabled") is False
80
+
81
+
82
+ def _cloudtrail_not_multi_region(r: Resource) -> bool:
83
+ return r.metadata.get("multi_region") is False
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # KMS
88
+ # ---------------------------------------------------------------------------
89
+
90
+ def _kms_rotation_disabled(r: Resource) -> bool:
91
+ return r.metadata.get("rotation_enabled") is False
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Lambda
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def _lambda_url_no_auth(r: Resource) -> bool:
99
+ return r.metadata.get("function_url_no_auth") is True
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Rule registry
104
+ # ---------------------------------------------------------------------------
105
+
106
+ RULES: list[Rule] = [
107
+ # S3
108
+ Rule(
109
+ id="CG-S3-001",
110
+ title="S3 bucket is publicly accessible",
111
+ severity="CRITICAL",
112
+ resource_type="storage_bucket",
113
+ check=_s3_public,
114
+ remediation="Enable S3 Block Public Access and remove public ACL/bucket policy grants.",
115
+ compliance=["CIS AWS 2.1.5", "SOC2 CC6.1", "PCI-DSS 1.3"],
116
+ ),
117
+ Rule(
118
+ id="CG-S3-002",
119
+ title="S3 bucket does not have default encryption enabled",
120
+ severity="MEDIUM",
121
+ resource_type="storage_bucket",
122
+ check=_s3_unencrypted,
123
+ remediation="Enable default SSE-S3 or SSE-KMS encryption on the bucket.",
124
+ compliance=["CIS AWS 2.1.1", "SOC2 CC6.7", "PCI-DSS 3.4"],
125
+ ),
126
+ # IAM
127
+ Rule(
128
+ id="CG-IAM-001",
129
+ title="IAM user does not have MFA enabled",
130
+ severity="HIGH",
131
+ resource_type="iam_user",
132
+ check=_iam_user_no_mfa,
133
+ remediation="Require and enable MFA for all IAM users with console access.",
134
+ compliance=["CIS AWS 1.10", "SOC2 CC6.1", "PCI-DSS 8.3"],
135
+ ),
136
+ Rule(
137
+ id="CG-IAM-002",
138
+ title="Root account does not have MFA enabled",
139
+ severity="CRITICAL",
140
+ resource_type="iam_account",
141
+ check=_root_no_mfa,
142
+ remediation="Enable MFA on the root account immediately.",
143
+ compliance=["CIS AWS 1.5", "SOC2 CC6.1", "PCI-DSS 8.3"],
144
+ ),
145
+ Rule(
146
+ id="CG-IAM-003",
147
+ title="Root account has active access keys",
148
+ severity="CRITICAL",
149
+ resource_type="iam_account",
150
+ check=_root_access_keys,
151
+ remediation="Delete root account access keys; use IAM roles/users for programmatic access instead.",
152
+ compliance=["CIS AWS 1.4", "SOC2 CC6.3", "PCI-DSS 7.1"],
153
+ ),
154
+ # EC2
155
+ Rule(
156
+ id="CG-EC2-001",
157
+ title="Security group allows ingress from 0.0.0.0/0",
158
+ severity="HIGH",
159
+ resource_type="security_group",
160
+ check=_sg_open_to_world,
161
+ remediation="Restrict security group ingress rules to specific known IP ranges.",
162
+ compliance=["CIS AWS 5.2", "SOC2 CC6.6", "PCI-DSS 1.2"],
163
+ ),
164
+ # RDS
165
+ Rule(
166
+ id="CG-RDS-001",
167
+ title="RDS instance is publicly accessible",
168
+ severity="CRITICAL",
169
+ resource_type="rds_instance",
170
+ check=_rds_publicly_accessible,
171
+ remediation="Set PubliclyAccessible=false and place the instance in a private subnet.",
172
+ compliance=["CIS AWS 2.3.2", "SOC2 CC6.1", "PCI-DSS 1.3"],
173
+ ),
174
+ Rule(
175
+ id="CG-RDS-002",
176
+ title="RDS instance storage is not encrypted",
177
+ severity="HIGH",
178
+ resource_type="rds_instance",
179
+ check=_rds_unencrypted,
180
+ remediation="Enable storage encryption on the RDS instance (requires snapshot restore for existing instances).",
181
+ compliance=["CIS AWS 2.3.1", "SOC2 CC6.7", "PCI-DSS 3.4"],
182
+ ),
183
+ # CloudTrail
184
+ Rule(
185
+ id="CG-CT-001",
186
+ title="CloudTrail is not enabled in this region",
187
+ severity="HIGH",
188
+ resource_type="cloudtrail_account",
189
+ check=_cloudtrail_not_enabled,
190
+ remediation="Enable CloudTrail with a trail logging to an S3 bucket and optionally CloudWatch Logs.",
191
+ compliance=["CIS AWS 3.1", "SOC2 CC7.2", "PCI-DSS 10.1"],
192
+ ),
193
+ Rule(
194
+ id="CG-CT-002",
195
+ title="No multi-region CloudTrail trail exists",
196
+ severity="MEDIUM",
197
+ resource_type="cloudtrail_account",
198
+ check=_cloudtrail_not_multi_region,
199
+ remediation="Configure at least one CloudTrail trail to cover all regions.",
200
+ compliance=["CIS AWS 3.1", "SOC2 CC7.2"],
201
+ ),
202
+ # KMS
203
+ Rule(
204
+ id="CG-KMS-001",
205
+ title="KMS customer-managed key does not have automatic rotation enabled",
206
+ severity="MEDIUM",
207
+ resource_type="kms_key",
208
+ check=_kms_rotation_disabled,
209
+ remediation="Enable automatic annual key rotation for all CMKs.",
210
+ compliance=["CIS AWS 3.7", "SOC2 CC6.7", "PCI-DSS 3.6"],
211
+ ),
212
+ # Lambda
213
+ Rule(
214
+ id="CG-LAMBDA-001",
215
+ title="Lambda function URL has no authentication (NONE auth type)",
216
+ severity="HIGH",
217
+ resource_type="lambda_function",
218
+ check=_lambda_url_no_auth,
219
+ remediation="Set function URL AuthType to AWS_IAM or remove the function URL if not needed.",
220
+ compliance=["SOC2 CC6.1", "PCI-DSS 8.3"],
221
+ ),
222
+ ]
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudglass
3
+ Version: 0.2.0
4
+ Summary: See through your cloud's security posture — open-source cloud misconfiguration scanner.
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: boto3>=1.34
9
+ Requires-Dist: click>=8.1
10
+ Requires-Dist: rich>=13.7
11
+ Requires-Dist: PyYAML>=6.0
12
+
13
+ # CloudGlass
14
+
15
+ See through your cloud's security posture. An open-source CLI scanner that
16
+ checks your AWS account for common misconfigurations — public S3 buckets,
17
+ missing MFA, permissive security groups, unencrypted RDS, disabled CloudTrail,
18
+ KMS rotation, and Lambda function URL exposure.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install -e .
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ cloudglass scan --profile your-aws-profile --region us-east-1
30
+ ```
31
+
32
+ ### Common flags
33
+
34
+ | Flag | Default | Description |
35
+ |---|---|---|
36
+ | `--profile` | (default chain) | AWS named profile |
37
+ | `--region` | `us-east-1` | Primary region |
38
+ | `--all-regions` | off | Scan every enabled region |
39
+ | `--output` | `table` | `table`, `json`, or `sarif` |
40
+ | `--fail-on` | `HIGH` | Exit 1 when severity ≥ this (`CRITICAL`/`HIGH`/`MEDIUM`/`LOW`/`NONE`) |
41
+ | `--severity` | all | Comma-separated filter e.g. `HIGH,CRITICAL` |
42
+ | `--service` | all | Comma-separated service filter e.g. `s3,rds` |
43
+ | `--rule-id` | all | Specific rule IDs e.g. `CG-S3-001` |
44
+ | `--rules-file` | none | Path to YAML custom rules file |
45
+ | `--compliance` | off | Show CIS/SOC2/PCI-DSS tags in table output |
46
+
47
+ ## What it checks (v0.2)
48
+
49
+ | Rule ID | Check | Severity | CIS | SOC2 | PCI-DSS |
50
+ |---|---|---|---|---|---|
51
+ | CG-S3-001 | S3 bucket is publicly accessible | CRITICAL | 2.1.5 | CC6.1 | 1.3 |
52
+ | CG-S3-002 | S3 bucket missing default encryption | MEDIUM | 2.1.1 | CC6.7 | 3.4 |
53
+ | CG-IAM-001 | IAM user has no MFA device | HIGH | 1.10 | CC6.1 | 8.3 |
54
+ | CG-IAM-002 | Root account has no MFA | CRITICAL | 1.5 | CC6.1 | 8.3 |
55
+ | CG-IAM-003 | Root account has active access keys | CRITICAL | 1.4 | CC6.3 | 7.1 |
56
+ | CG-EC2-001 | Security group open to 0.0.0.0/0 or ::/0 | HIGH | 5.2 | CC6.6 | 1.2 |
57
+ | CG-RDS-001 | RDS instance is publicly accessible | CRITICAL | 2.3.2 | CC6.1 | 1.3 |
58
+ | CG-RDS-002 | RDS instance storage not encrypted | HIGH | 2.3.1 | CC6.7 | 3.4 |
59
+ | CG-CT-001 | CloudTrail not enabled in region | HIGH | 3.1 | CC7.2 | 10.1 |
60
+ | CG-CT-002 | No multi-region CloudTrail trail | MEDIUM | 3.1 | CC7.2 | — |
61
+ | CG-KMS-001 | KMS CMK rotation disabled | MEDIUM | 3.7 | CC6.7 | 3.6 |
62
+ | CG-LAMBDA-001 | Lambda function URL with no auth | HIGH | — | CC6.1 | 8.3 |
63
+
64
+ > **Note:** Compliance tags are indicative starting points, not certified mappings.
65
+
66
+ ## Custom rules (YAML)
67
+
68
+ Create a YAML file and pass it with `--rules-file`:
69
+
70
+ ```yaml
71
+ - id: MY-S3-001
72
+ title: S3 bucket is not encrypted
73
+ severity: HIGH
74
+ resource_type: storage_bucket
75
+ conditions:
76
+ - field: encrypted
77
+ op: is_false
78
+ remediation: Enable SSE-KMS encryption on all buckets.
79
+ compliance:
80
+ - "CIS 2.1.1"
81
+ ```
82
+
83
+ Supported `op` values: `equals`, `not_equals`, `contains`, `is_true`, `is_false`, `exists`.
84
+ Use `field: metadata.<key>` to access nested metadata fields.
85
+
86
+ ## CI/CD integration
87
+
88
+ ```bash
89
+ # Fail the pipeline if CRITICAL findings exist
90
+ cloudglass scan --output json --fail-on CRITICAL | jq .
91
+
92
+ # SARIF output for GitHub Code Scanning
93
+ cloudglass scan --output sarif > results.sarif
94
+ ```
95
+
96
+ ## Required IAM permissions
97
+
98
+ CloudGlass is **read-only**. Minimum managed policies:
99
+
100
+ - `arn:aws:iam::aws:policy/SecurityAudit`
101
+ - `arn:aws:iam::aws:policy/job-function/ViewOnlyAccess`
102
+
103
+ Never grant this tool write permissions.
104
+
105
+ ## Architecture
106
+
107
+ ```
108
+ collectors/ → pull raw resources from AWS APIs, normalize into Resource objects
109
+ models.py → the shared Resource schema (provider-agnostic)
110
+ rules/ → checks that run against normalized resources
111
+ engine.py → matches resources to applicable rules, produces findings
112
+ cli.py → entrypoint: scan, collect, run rules, print results
113
+ ```
114
+
115
+ ## Roadmap
116
+
117
+ - [ ] Azure collector (App Registration / Reader role)
118
+ - [ ] GCP collector (Workload Identity Federation)
119
+ - [ ] Additional services: EKS, ECS, SNS, SQS, Secrets Manager
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,19 @@
1
+ cloudglass/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ cloudglass/cli.py,sha256=dlAr__8bMTETW_0prPjy56vD3XEfOAc9OmvvxGnyQCg,13790
3
+ cloudglass/engine.py,sha256=oJi3sBMMiMARZ0IfV4XjZfYVgZkiWYOen1JJW56QnCw,4995
4
+ cloudglass/models.py,sha256=yWyG_fME6g2XUy5RW5N_zHg0HI64oTNwMkvYS392VUA,1011
5
+ cloudglass/collectors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ cloudglass/collectors/cloudtrail.py,sha256=TizBhNlGPLov_ohTJi6S-VXVL5d41wMi8Vuvktwz-yU,1613
7
+ cloudglass/collectors/ec2.py,sha256=V93FoEi5cBpUnFmj7gryz-aKN1xXV8kdA1bUmnd5gMo,2193
8
+ cloudglass/collectors/iam.py,sha256=eBykrXkHQlJxJgQsz8i_H2KSzfFdIkBPwloIOwcBDuQ,2504
9
+ cloudglass/collectors/kms.py,sha256=g2R6IOfKmZMaNob-TsSEmTQx85xygxn5Dy4BV833xgw,2593
10
+ cloudglass/collectors/lambda_.py,sha256=LXxymeWxYpaUwZuWJWwIoGXXpt-AHVUERf0rdE9EAxQ,2200
11
+ cloudglass/collectors/rds.py,sha256=JvuiiLT-NwJ2aBi2MpWXSg6LTxQ2dHcZ-ZMUDrj6KJI,1794
12
+ cloudglass/collectors/s3.py,sha256=cQYQhQrNldKpTrAsa74uT9M0hjv6IDKvrK_cN8YE1EM,2434
13
+ cloudglass/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ cloudglass/rules/aws_rules.py,sha256=6lStsYt6WVY8jQg8LPyazmBTmMmYVtA4ymyNSIkBlg4,7393
15
+ cloudglass-0.2.0.dist-info/METADATA,sha256=31Sojzp26sq9GVofdazMmMFuKjuHxSXsgxts0y1Z5DY,4125
16
+ cloudglass-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ cloudglass-0.2.0.dist-info/entry_points.txt,sha256=1GcgGOlh6Vk8nkoK0hzZlwf4QRPMJtlQNm2Ctd5TdAE,51
18
+ cloudglass-0.2.0.dist-info/top_level.txt,sha256=xwjKQUuVYcUUKGkhqSzbeWwrQ4QvJAUuyb9pekg0rR0,11
19
+ cloudglass-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cloudglass = cloudglass.cli:main
@@ -0,0 +1 @@
1
+ cloudglass