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.
- cloudglass-0.2.0/PKG-INFO +123 -0
- cloudglass-0.2.0/README.md +111 -0
- cloudglass-0.2.0/cloudglass/__init__.py +1 -0
- cloudglass-0.2.0/cloudglass/cli.py +358 -0
- cloudglass-0.2.0/cloudglass/collectors/__init__.py +0 -0
- cloudglass-0.2.0/cloudglass/collectors/cloudtrail.py +47 -0
- cloudglass-0.2.0/cloudglass/collectors/ec2.py +51 -0
- cloudglass-0.2.0/cloudglass/collectors/iam.py +72 -0
- cloudglass-0.2.0/cloudglass/collectors/kms.py +64 -0
- cloudglass-0.2.0/cloudglass/collectors/lambda_.py +54 -0
- cloudglass-0.2.0/cloudglass/collectors/rds.py +43 -0
- cloudglass-0.2.0/cloudglass/collectors/s3.py +78 -0
- cloudglass-0.2.0/cloudglass/engine.py +153 -0
- cloudglass-0.2.0/cloudglass/models.py +25 -0
- cloudglass-0.2.0/cloudglass/rules/__init__.py +0 -0
- cloudglass-0.2.0/cloudglass/rules/aws_rules.py +222 -0
- cloudglass-0.2.0/cloudglass.egg-info/PKG-INFO +123 -0
- cloudglass-0.2.0/cloudglass.egg-info/SOURCES.txt +22 -0
- cloudglass-0.2.0/cloudglass.egg-info/dependency_links.txt +1 -0
- cloudglass-0.2.0/cloudglass.egg-info/entry_points.txt +2 -0
- cloudglass-0.2.0/cloudglass.egg-info/requires.txt +4 -0
- cloudglass-0.2.0/cloudglass.egg-info/top_level.txt +1 -0
- cloudglass-0.2.0/pyproject.toml +23 -0
- cloudglass-0.2.0/setup.cfg +4 -0
|
@@ -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
|
+
]
|