cloudglass 0.2.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.
@@ -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,111 @@
1
+ # CloudGlass
2
+
3
+ See through your cloud's security posture. An open-source CLI scanner that
4
+ checks your AWS account for common misconfigurations — public S3 buckets,
5
+ missing MFA, permissive security groups, unencrypted RDS, disabled CloudTrail,
6
+ KMS rotation, and Lambda function URL exposure.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install -e .
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ cloudglass scan --profile your-aws-profile --region us-east-1
18
+ ```
19
+
20
+ ### Common flags
21
+
22
+ | Flag | Default | Description |
23
+ |---|---|---|
24
+ | `--profile` | (default chain) | AWS named profile |
25
+ | `--region` | `us-east-1` | Primary region |
26
+ | `--all-regions` | off | Scan every enabled region |
27
+ | `--output` | `table` | `table`, `json`, or `sarif` |
28
+ | `--fail-on` | `HIGH` | Exit 1 when severity ≥ this (`CRITICAL`/`HIGH`/`MEDIUM`/`LOW`/`NONE`) |
29
+ | `--severity` | all | Comma-separated filter e.g. `HIGH,CRITICAL` |
30
+ | `--service` | all | Comma-separated service filter e.g. `s3,rds` |
31
+ | `--rule-id` | all | Specific rule IDs e.g. `CG-S3-001` |
32
+ | `--rules-file` | none | Path to YAML custom rules file |
33
+ | `--compliance` | off | Show CIS/SOC2/PCI-DSS tags in table output |
34
+
35
+ ## What it checks (v0.2)
36
+
37
+ | Rule ID | Check | Severity | CIS | SOC2 | PCI-DSS |
38
+ |---|---|---|---|---|---|
39
+ | CG-S3-001 | S3 bucket is publicly accessible | CRITICAL | 2.1.5 | CC6.1 | 1.3 |
40
+ | CG-S3-002 | S3 bucket missing default encryption | MEDIUM | 2.1.1 | CC6.7 | 3.4 |
41
+ | CG-IAM-001 | IAM user has no MFA device | HIGH | 1.10 | CC6.1 | 8.3 |
42
+ | CG-IAM-002 | Root account has no MFA | CRITICAL | 1.5 | CC6.1 | 8.3 |
43
+ | CG-IAM-003 | Root account has active access keys | CRITICAL | 1.4 | CC6.3 | 7.1 |
44
+ | CG-EC2-001 | Security group open to 0.0.0.0/0 or ::/0 | HIGH | 5.2 | CC6.6 | 1.2 |
45
+ | CG-RDS-001 | RDS instance is publicly accessible | CRITICAL | 2.3.2 | CC6.1 | 1.3 |
46
+ | CG-RDS-002 | RDS instance storage not encrypted | HIGH | 2.3.1 | CC6.7 | 3.4 |
47
+ | CG-CT-001 | CloudTrail not enabled in region | HIGH | 3.1 | CC7.2 | 10.1 |
48
+ | CG-CT-002 | No multi-region CloudTrail trail | MEDIUM | 3.1 | CC7.2 | — |
49
+ | CG-KMS-001 | KMS CMK rotation disabled | MEDIUM | 3.7 | CC6.7 | 3.6 |
50
+ | CG-LAMBDA-001 | Lambda function URL with no auth | HIGH | — | CC6.1 | 8.3 |
51
+
52
+ > **Note:** Compliance tags are indicative starting points, not certified mappings.
53
+
54
+ ## Custom rules (YAML)
55
+
56
+ Create a YAML file and pass it with `--rules-file`:
57
+
58
+ ```yaml
59
+ - id: MY-S3-001
60
+ title: S3 bucket is not encrypted
61
+ severity: HIGH
62
+ resource_type: storage_bucket
63
+ conditions:
64
+ - field: encrypted
65
+ op: is_false
66
+ remediation: Enable SSE-KMS encryption on all buckets.
67
+ compliance:
68
+ - "CIS 2.1.1"
69
+ ```
70
+
71
+ Supported `op` values: `equals`, `not_equals`, `contains`, `is_true`, `is_false`, `exists`.
72
+ Use `field: metadata.<key>` to access nested metadata fields.
73
+
74
+ ## CI/CD integration
75
+
76
+ ```bash
77
+ # Fail the pipeline if CRITICAL findings exist
78
+ cloudglass scan --output json --fail-on CRITICAL | jq .
79
+
80
+ # SARIF output for GitHub Code Scanning
81
+ cloudglass scan --output sarif > results.sarif
82
+ ```
83
+
84
+ ## Required IAM permissions
85
+
86
+ CloudGlass is **read-only**. Minimum managed policies:
87
+
88
+ - `arn:aws:iam::aws:policy/SecurityAudit`
89
+ - `arn:aws:iam::aws:policy/job-function/ViewOnlyAccess`
90
+
91
+ Never grant this tool write permissions.
92
+
93
+ ## Architecture
94
+
95
+ ```
96
+ collectors/ → pull raw resources from AWS APIs, normalize into Resource objects
97
+ models.py → the shared Resource schema (provider-agnostic)
98
+ rules/ → checks that run against normalized resources
99
+ engine.py → matches resources to applicable rules, produces findings
100
+ cli.py → entrypoint: scan, collect, run rules, print results
101
+ ```
102
+
103
+ ## Roadmap
104
+
105
+ - [ ] Azure collector (App Registration / Reader role)
106
+ - [ ] GCP collector (Workload Identity Federation)
107
+ - [ ] Additional services: EKS, ECS, SNS, SQS, Secrets Manager
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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
+ ]