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 +1 -0
- cloudglass/cli.py +358 -0
- cloudglass/collectors/__init__.py +0 -0
- cloudglass/collectors/cloudtrail.py +47 -0
- cloudglass/collectors/ec2.py +51 -0
- cloudglass/collectors/iam.py +72 -0
- cloudglass/collectors/kms.py +64 -0
- cloudglass/collectors/lambda_.py +54 -0
- cloudglass/collectors/rds.py +43 -0
- cloudglass/collectors/s3.py +78 -0
- cloudglass/engine.py +153 -0
- cloudglass/models.py +25 -0
- cloudglass/rules/__init__.py +0 -0
- cloudglass/rules/aws_rules.py +222 -0
- cloudglass-0.2.0.dist-info/METADATA +123 -0
- cloudglass-0.2.0.dist-info/RECORD +19 -0
- cloudglass-0.2.0.dist-info/WHEEL +5 -0
- cloudglass-0.2.0.dist-info/entry_points.txt +2 -0
- cloudglass-0.2.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
cloudglass
|