cloudleak 1.0.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.
- cloudleak/__init__.py +6 -0
- cloudleak/auth/__init__.py +0 -0
- cloudleak/auth/session.py +53 -0
- cloudleak/cli/__init__.py +531 -0
- cloudleak/config/__init__.py +0 -0
- cloudleak/config/loader.py +187 -0
- cloudleak/output/__init__.py +8 -0
- cloudleak/output/console.py +266 -0
- cloudleak/output/csv_.py +44 -0
- cloudleak/output/executive.py +26 -0
- cloudleak/output/executive_model.py +319 -0
- cloudleak/output/html_.py +921 -0
- cloudleak/output/json_.py +76 -0
- cloudleak/output/templates/executive.html.j2 +506 -0
- cloudleak/pricing/__init__.py +0 -0
- cloudleak/pricing/cache.py +172 -0
- cloudleak/pricing/prices.json +4 -0
- cloudleak/pricing/update.py +381 -0
- cloudleak/rules/__init__.py +90 -0
- cloudleak/rules/aiml.py +1019 -0
- cloudleak/rules/analytics.py +939 -0
- cloudleak/rules/appsync.py +140 -0
- cloudleak/rules/batch.py +154 -0
- cloudleak/rules/cloudwatch.py +586 -0
- cloudleak/rules/codebuild.py +139 -0
- cloudleak/rules/codepipeline.py +135 -0
- cloudleak/rules/connect.py +123 -0
- cloudleak/rules/containers.py +754 -0
- cloudleak/rules/datasync.py +109 -0
- cloudleak/rules/desktop.py +318 -0
- cloudleak/rules/ebs.py +866 -0
- cloudleak/rules/ec2.py +1088 -0
- cloudleak/rules/gamelift.py +239 -0
- cloudleak/rules/governance.py +443 -0
- cloudleak/rules/grafana.py +119 -0
- cloudleak/rules/ivs.py +116 -0
- cloudleak/rules/lambda_.py +997 -0
- cloudleak/rules/lightsail.py +142 -0
- cloudleak/rules/messaging.py +420 -0
- cloudleak/rules/networking.py +1378 -0
- cloudleak/rules/prometheus.py +116 -0
- cloudleak/rules/rds.py +1542 -0
- cloudleak/rules/s3.py +905 -0
- cloudleak/rules/security.py +753 -0
- cloudleak/rules/transcoder.py +132 -0
- cloudleak/rules/transfer.py +123 -0
- cloudleak/scanner/__init__.py +0 -0
- cloudleak/scanner/cloudwatch.py +154 -0
- cloudleak/scanner/compute_optimizer.py +82 -0
- cloudleak/scanner/context.py +21 -0
- cloudleak/scanner/doctor.py +378 -0
- cloudleak/scanner/finding.py +70 -0
- cloudleak/scanner/orchestrator.py +167 -0
- cloudleak/scanner/rule.py +23 -0
- cloudleak-1.0.0.dist-info/METADATA +518 -0
- cloudleak-1.0.0.dist-info/RECORD +60 -0
- cloudleak-1.0.0.dist-info/WHEEL +5 -0
- cloudleak-1.0.0.dist-info/entry_points.txt +2 -0
- cloudleak-1.0.0.dist-info/licenses/LICENSE +21 -0
- cloudleak-1.0.0.dist-info/top_level.txt +1 -0
cloudleak/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import boto3
|
|
2
|
+
import botocore.exceptions
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CloudLeakAuthError(Exception):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_session(profile: str | None = None) -> boto3.Session:
|
|
10
|
+
try:
|
|
11
|
+
session = boto3.Session(profile_name=profile) if profile else boto3.Session()
|
|
12
|
+
creds = session.get_credentials()
|
|
13
|
+
if creds is None:
|
|
14
|
+
raise CloudLeakAuthError(
|
|
15
|
+
"No AWS credentials found. Configure via environment variables, "
|
|
16
|
+
"~/.aws/credentials, or SSO."
|
|
17
|
+
)
|
|
18
|
+
return session
|
|
19
|
+
except botocore.exceptions.ProfileNotFound as exc:
|
|
20
|
+
raise CloudLeakAuthError(f"AWS profile not found: {exc}") from exc
|
|
21
|
+
except botocore.exceptions.NoCredentialsError as exc:
|
|
22
|
+
raise CloudLeakAuthError("No AWS credentials found.") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_account_id(session: boto3.Session) -> str:
|
|
26
|
+
try:
|
|
27
|
+
sts = session.client("sts")
|
|
28
|
+
return sts.get_caller_identity()["Account"]
|
|
29
|
+
except botocore.exceptions.ClientError as exc:
|
|
30
|
+
raise CloudLeakAuthError(f"Failed to call sts:GetCallerIdentity — {exc}") from exc
|
|
31
|
+
except botocore.exceptions.NoCredentialsError as exc:
|
|
32
|
+
raise CloudLeakAuthError("No AWS credentials found.") from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_active_regions(session: boto3.Session) -> list[str]:
|
|
36
|
+
try:
|
|
37
|
+
ec2 = session.client("ec2", region_name="us-east-1")
|
|
38
|
+
resp = ec2.describe_regions(
|
|
39
|
+
Filters=[
|
|
40
|
+
{
|
|
41
|
+
"Name": "opt-in-status",
|
|
42
|
+
"Values": ["opt-in-not-required", "opted-in"],
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
return sorted(r["RegionName"] for r in resp["Regions"])
|
|
47
|
+
except botocore.exceptions.ClientError as exc:
|
|
48
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
49
|
+
if code in ("AccessDenied", "UnauthorizedOperation"):
|
|
50
|
+
raise CloudLeakAuthError(
|
|
51
|
+
f"Missing permission: ec2:DescribeRegions. Grant it to run --all-regions."
|
|
52
|
+
) from exc
|
|
53
|
+
raise CloudLeakAuthError(f"Failed to list regions: {exc}") from exc
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
# Ensure stdout/stderr can handle Unicode on Windows (cp1252 can't encode emoji severity icons).
|
|
9
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
10
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
11
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
12
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
13
|
+
|
|
14
|
+
_console = Console(legacy_windows=False)
|
|
15
|
+
_err = Console(stderr=True, legacy_windows=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
@click.version_option(package_name="cloudleak")
|
|
20
|
+
def main() -> None:
|
|
21
|
+
"""CloudLeak — read-only AWS FinOps CLI scanner."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ─── scan ──────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@main.group()
|
|
28
|
+
def scan() -> None:
|
|
29
|
+
"""Scan AWS resources for cost waste."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@scan.group(invoke_without_command=True)
|
|
33
|
+
@click.option("--all-regions", is_flag=True, default=False, help="Scan all active regions.")
|
|
34
|
+
@click.option("--profile", default=None, help="AWS named profile to use.")
|
|
35
|
+
@click.option(
|
|
36
|
+
"--export",
|
|
37
|
+
"export_formats",
|
|
38
|
+
type=click.Choice(["json", "csv", "html", "executive", "all"]),
|
|
39
|
+
multiple=True,
|
|
40
|
+
help="Export findings to file (may be specified multiple times; 'all' writes every format).",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--config",
|
|
44
|
+
"config_path",
|
|
45
|
+
default=None,
|
|
46
|
+
type=click.Path(exists=True),
|
|
47
|
+
help="Path to cloudleak.yaml config file.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--rules",
|
|
51
|
+
default=None,
|
|
52
|
+
help="Comma-separated rule groups to run (e.g. ec2,rds,s3).",
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--severity",
|
|
56
|
+
type=click.Choice(["HIGH", "MEDIUM", "LOW"]),
|
|
57
|
+
default=None,
|
|
58
|
+
help="Only emit findings at or above this severity.",
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--output-file",
|
|
62
|
+
default=None,
|
|
63
|
+
type=click.Path(),
|
|
64
|
+
help="Write export output to this file (default: stdout).",
|
|
65
|
+
)
|
|
66
|
+
@click.option("-v", "--verbose", is_flag=True, default=False, help="Verbose scan progress.")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def aws(
|
|
69
|
+
ctx: click.Context,
|
|
70
|
+
all_regions: bool,
|
|
71
|
+
profile: str | None,
|
|
72
|
+
export_formats: tuple[str, ...],
|
|
73
|
+
config_path: str | None,
|
|
74
|
+
rules: str | None,
|
|
75
|
+
severity: str | None,
|
|
76
|
+
output_file: str | None,
|
|
77
|
+
verbose: bool,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Scan AWS resources (specify a region subcommand or --all-regions)."""
|
|
80
|
+
ctx.ensure_object(dict)
|
|
81
|
+
ctx.obj.update(
|
|
82
|
+
{
|
|
83
|
+
"all_regions": all_regions,
|
|
84
|
+
"profile": profile,
|
|
85
|
+
"export_formats": export_formats,
|
|
86
|
+
"config_path": config_path,
|
|
87
|
+
"rules": rules,
|
|
88
|
+
"severity": severity,
|
|
89
|
+
"output_file": output_file,
|
|
90
|
+
"verbose": verbose,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
if ctx.invoked_subcommand is None:
|
|
94
|
+
if all_regions:
|
|
95
|
+
_run_scan(ctx.obj, regions=None)
|
|
96
|
+
else:
|
|
97
|
+
_err.print(
|
|
98
|
+
"[yellow]Specify a region (e.g. cloudleak scan aws region us-east-1) "
|
|
99
|
+
"or use --all-regions.[/yellow]"
|
|
100
|
+
)
|
|
101
|
+
click.echo(ctx.get_help())
|
|
102
|
+
ctx.exit(1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@aws.command(name="region")
|
|
106
|
+
@click.argument("region_name")
|
|
107
|
+
@click.option(
|
|
108
|
+
"--export",
|
|
109
|
+
"export_formats",
|
|
110
|
+
type=click.Choice(["json", "csv", "html", "executive", "all"]),
|
|
111
|
+
multiple=True,
|
|
112
|
+
help="Export findings to file (may be specified multiple times; 'all' writes every format).",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--config",
|
|
116
|
+
"config_path",
|
|
117
|
+
default=None,
|
|
118
|
+
type=click.Path(exists=True),
|
|
119
|
+
help="Path to cloudleak.yaml config file.",
|
|
120
|
+
)
|
|
121
|
+
@click.option("--rules", default=None, help="Comma-separated rule groups to run (e.g. ec2,rds,s3).")
|
|
122
|
+
@click.option(
|
|
123
|
+
"--severity",
|
|
124
|
+
type=click.Choice(["HIGH", "MEDIUM", "LOW"]),
|
|
125
|
+
default=None,
|
|
126
|
+
help="Only emit findings at or above this severity.",
|
|
127
|
+
)
|
|
128
|
+
@click.option(
|
|
129
|
+
"--output-file",
|
|
130
|
+
default=None,
|
|
131
|
+
type=click.Path(),
|
|
132
|
+
help="Write export output to this file (default: auto-named).",
|
|
133
|
+
)
|
|
134
|
+
@click.option("--profile", default=None, help="AWS named profile to use.")
|
|
135
|
+
@click.option("-v", "--verbose", is_flag=True, default=False, help="Verbose scan progress.")
|
|
136
|
+
@click.pass_obj
|
|
137
|
+
def aws_region(
|
|
138
|
+
obj: dict,
|
|
139
|
+
region_name: str,
|
|
140
|
+
export_formats: tuple[str, ...],
|
|
141
|
+
config_path: str | None,
|
|
142
|
+
rules: str | None,
|
|
143
|
+
severity: str | None,
|
|
144
|
+
output_file: str | None,
|
|
145
|
+
profile: str | None,
|
|
146
|
+
verbose: bool,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Scan a single AWS REGION (e.g. us-east-1)."""
|
|
149
|
+
if export_formats:
|
|
150
|
+
obj["export_formats"] = export_formats
|
|
151
|
+
if config_path is not None:
|
|
152
|
+
obj["config_path"] = config_path
|
|
153
|
+
if rules is not None:
|
|
154
|
+
obj["rules"] = rules
|
|
155
|
+
if severity is not None:
|
|
156
|
+
obj["severity"] = severity
|
|
157
|
+
if output_file is not None:
|
|
158
|
+
obj["output_file"] = output_file
|
|
159
|
+
if profile is not None:
|
|
160
|
+
obj["profile"] = profile
|
|
161
|
+
if verbose:
|
|
162
|
+
obj["verbose"] = verbose
|
|
163
|
+
_run_scan(obj, regions=[region_name])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _run_scan(opts: dict, regions: list[str] | None) -> None:
|
|
167
|
+
import time
|
|
168
|
+
from datetime import datetime, timezone
|
|
169
|
+
from pathlib import Path
|
|
170
|
+
|
|
171
|
+
from cloudleak.auth.session import (
|
|
172
|
+
CloudLeakAuthError,
|
|
173
|
+
get_account_id,
|
|
174
|
+
get_active_regions,
|
|
175
|
+
get_session,
|
|
176
|
+
)
|
|
177
|
+
from cloudleak.config.loader import load_config
|
|
178
|
+
from cloudleak.output import render_console, render_csv, render_executive, render_html, render_json
|
|
179
|
+
from cloudleak.output.executive_model import build_executive_summary
|
|
180
|
+
from cloudleak.pricing.cache import PricingCache
|
|
181
|
+
from cloudleak.rules import get_rules
|
|
182
|
+
from cloudleak.scanner.orchestrator import scan_region
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
session = get_session(opts.get("profile"))
|
|
186
|
+
except CloudLeakAuthError as exc:
|
|
187
|
+
_err.print(f"[red]Auth error:[/red] {exc}")
|
|
188
|
+
sys.exit(2)
|
|
189
|
+
|
|
190
|
+
cfg = load_config(opts.get("config_path"))
|
|
191
|
+
|
|
192
|
+
cache_path = Path(cfg.pricing.get("cache_file", "~/.cloudleak/prices.json")).expanduser()
|
|
193
|
+
cache = PricingCache.load(cache_path)
|
|
194
|
+
warn_days = int(cfg.pricing.get("warn_if_stale_days", 30))
|
|
195
|
+
if cache.is_empty:
|
|
196
|
+
_err.print(
|
|
197
|
+
"[yellow]Warning:[/yellow] Pricing cache not found — "
|
|
198
|
+
"run [bold]cloudleak prices update[/bold]. All cost estimates will show as unknown."
|
|
199
|
+
)
|
|
200
|
+
elif cache.is_stale(warn_days):
|
|
201
|
+
_err.print(
|
|
202
|
+
f"[yellow]Warning:[/yellow] Pricing cache is older than {warn_days} days. "
|
|
203
|
+
f"Run [bold]cloudleak prices update[/bold] to refresh."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
account_id = get_account_id(session)
|
|
208
|
+
except CloudLeakAuthError as exc:
|
|
209
|
+
_err.print(f"[red]Auth error:[/red] {exc}")
|
|
210
|
+
sys.exit(2)
|
|
211
|
+
|
|
212
|
+
if regions is None:
|
|
213
|
+
try:
|
|
214
|
+
regions = get_active_regions(session)
|
|
215
|
+
except CloudLeakAuthError as exc:
|
|
216
|
+
_err.print(f"[red]Auth error:[/red] {exc}")
|
|
217
|
+
sys.exit(2)
|
|
218
|
+
|
|
219
|
+
# Resolve rule filter flags
|
|
220
|
+
groups = [g.strip().lower() for g in opts["rules"].split(",")] if opts.get("rules") else None
|
|
221
|
+
severity = opts.get("severity")
|
|
222
|
+
rules = get_rules(groups=groups, severity=severity)
|
|
223
|
+
|
|
224
|
+
_console.print(
|
|
225
|
+
f"[bold]CloudLeak[/bold] — account [cyan]{account_id}[/cyan] | "
|
|
226
|
+
f"{len(regions)} region(s) | {len(rules)} rule(s)"
|
|
227
|
+
)
|
|
228
|
+
if not rules:
|
|
229
|
+
_console.print("[yellow]No rules registered. Rule groups are implemented in Epic 4+.[/yellow]")
|
|
230
|
+
|
|
231
|
+
scan_time = datetime.now(timezone.utc)
|
|
232
|
+
wall_start = time.monotonic()
|
|
233
|
+
all_findings = []
|
|
234
|
+
all_skipped = []
|
|
235
|
+
all_rule_runs = []
|
|
236
|
+
|
|
237
|
+
for region in regions:
|
|
238
|
+
result = scan_region(
|
|
239
|
+
session=session,
|
|
240
|
+
region=region,
|
|
241
|
+
account_id=account_id,
|
|
242
|
+
rules=rules,
|
|
243
|
+
config=cfg,
|
|
244
|
+
pricing=cache,
|
|
245
|
+
)
|
|
246
|
+
all_findings.extend(result.findings)
|
|
247
|
+
all_skipped.extend(result.skipped)
|
|
248
|
+
all_rule_runs.extend(result.rule_runs)
|
|
249
|
+
|
|
250
|
+
wall = round(time.monotonic() - wall_start, 1)
|
|
251
|
+
suppress_skipped = cfg.output.get("suppress_skipped", False) if hasattr(cfg, "output") else False
|
|
252
|
+
|
|
253
|
+
# Console output (always shown)
|
|
254
|
+
render_console(
|
|
255
|
+
all_findings=all_findings,
|
|
256
|
+
all_skipped=all_skipped,
|
|
257
|
+
all_rule_runs=all_rule_runs,
|
|
258
|
+
account_id=account_id,
|
|
259
|
+
regions=regions,
|
|
260
|
+
duration=wall,
|
|
261
|
+
scan_time=scan_time,
|
|
262
|
+
console=_console,
|
|
263
|
+
suppress_skipped=suppress_skipped,
|
|
264
|
+
verbose=opts.get("verbose", False),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# File export(s)
|
|
268
|
+
raw_formats: tuple[str, ...] = opts.get("export_formats") or ()
|
|
269
|
+
formats: set[str] = set(raw_formats)
|
|
270
|
+
if "all" in formats:
|
|
271
|
+
formats = {"json", "csv", "html", "executive"}
|
|
272
|
+
if not formats:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
output_file = opts.get("output_file")
|
|
276
|
+
date_str = scan_time.strftime("%Y-%m-%d")
|
|
277
|
+
|
|
278
|
+
def _write(content: str, ext: str, suffix: str = "") -> None:
|
|
279
|
+
if output_file and len(formats) == 1:
|
|
280
|
+
path = Path(output_file)
|
|
281
|
+
else:
|
|
282
|
+
base = f"cloudleak-{suffix or 'findings'}-{date_str}"
|
|
283
|
+
path = Path(f"{base}.{ext}")
|
|
284
|
+
path.write_text(content, encoding="utf-8")
|
|
285
|
+
_console.print(f"[dim]Exported {ext.upper()} → {path}[/dim]")
|
|
286
|
+
|
|
287
|
+
if "json" in formats:
|
|
288
|
+
_write(
|
|
289
|
+
render_json(
|
|
290
|
+
all_findings=all_findings,
|
|
291
|
+
all_skipped=all_skipped,
|
|
292
|
+
account_id=account_id,
|
|
293
|
+
regions=regions,
|
|
294
|
+
duration=wall,
|
|
295
|
+
rules_executed=len(rules),
|
|
296
|
+
scan_time=scan_time,
|
|
297
|
+
),
|
|
298
|
+
"json",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if "csv" in formats:
|
|
302
|
+
_write(render_csv(all_findings=all_findings), "csv")
|
|
303
|
+
|
|
304
|
+
if "html" in formats:
|
|
305
|
+
_write(
|
|
306
|
+
render_html(
|
|
307
|
+
all_findings=all_findings,
|
|
308
|
+
all_skipped=all_skipped,
|
|
309
|
+
account_id=account_id,
|
|
310
|
+
regions=regions,
|
|
311
|
+
duration=wall,
|
|
312
|
+
scan_time=scan_time,
|
|
313
|
+
rules_evaluated=len(all_rule_runs),
|
|
314
|
+
),
|
|
315
|
+
"html",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if "executive" in formats:
|
|
319
|
+
effort_overrides = cfg.get("executive_report", "effort_overrides") or {}
|
|
320
|
+
summary = build_executive_summary(
|
|
321
|
+
all_findings=all_findings,
|
|
322
|
+
all_rule_runs=all_rule_runs,
|
|
323
|
+
account_id=account_id,
|
|
324
|
+
regions=regions,
|
|
325
|
+
duration=wall,
|
|
326
|
+
scan_time=scan_time.strftime("%Y-%m-%d %H:%M UTC"),
|
|
327
|
+
effort_overrides={k: float(v) for k, v in effort_overrides.items()},
|
|
328
|
+
)
|
|
329
|
+
_write(render_executive(summary), "html", suffix=f"executive-{account_id}-{regions[0] if regions else 'multi'}")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ─── doctor ────────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@main.group()
|
|
336
|
+
def doctor() -> None:
|
|
337
|
+
"""Pre-flight checks."""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@doctor.command(name="aws")
|
|
341
|
+
@click.option("--profile", default=None, help="AWS named profile to use.")
|
|
342
|
+
@click.option("--region", default=None, help="AWS region to probe (defaults to session region or us-east-1).")
|
|
343
|
+
def doctor_aws(profile: str | None, region: str | None) -> None:
|
|
344
|
+
"""Validate that the caller has all IAM permissions required by cloudleak."""
|
|
345
|
+
from rich.table import Table
|
|
346
|
+
|
|
347
|
+
from cloudleak.auth.session import CloudLeakAuthError, get_session
|
|
348
|
+
from cloudleak.scanner.doctor import DoctorAuthError, _PROBES, run_doctor
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
session = get_session(profile)
|
|
352
|
+
except CloudLeakAuthError as exc:
|
|
353
|
+
_err.print(f"[red]Auth error:[/red] {exc}")
|
|
354
|
+
sys.exit(2)
|
|
355
|
+
|
|
356
|
+
probe_region = region or session.region_name or "us-east-1"
|
|
357
|
+
|
|
358
|
+
_console.print()
|
|
359
|
+
_console.print("[bold]cloudleak doctor[/bold] — Permission Check")
|
|
360
|
+
_console.print(f"[dim]Probing {len(_PROBES)} permissions in [cyan]{probe_region}[/cyan] ...[/dim]")
|
|
361
|
+
_console.print()
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
result = run_doctor(session, probe_region)
|
|
365
|
+
except DoctorAuthError as exc:
|
|
366
|
+
_err.print(f"[red]Auth error:[/red] {exc}")
|
|
367
|
+
sys.exit(2)
|
|
368
|
+
except Exception as exc:
|
|
369
|
+
_err.print(f"[red]Doctor check failed:[/red] {exc}")
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
|
|
372
|
+
_console.print(
|
|
373
|
+
f"Account: [bold]{result.account_id}[/bold] · "
|
|
374
|
+
f"Identity: [dim]{result.caller_arn}[/dim]"
|
|
375
|
+
)
|
|
376
|
+
_console.print()
|
|
377
|
+
|
|
378
|
+
tbl = Table(show_header=True, header_style="bold dim", box=None, padding=(0, 2))
|
|
379
|
+
tbl.add_column("", width=3)
|
|
380
|
+
tbl.add_column("Permission")
|
|
381
|
+
tbl.add_column("Affected Rules", style="dim")
|
|
382
|
+
|
|
383
|
+
for r in result.results:
|
|
384
|
+
icon = "[green]✓[/green]" if r.ok else "[red]✗[/red]"
|
|
385
|
+
skipped = ", ".join(r.affected_rules) if (not r.ok and r.affected_rules) else ""
|
|
386
|
+
note_suffix = f" [dim italic]({r.note})[/dim italic]" if r.note else ""
|
|
387
|
+
tbl.add_row(icon, r.permission + note_suffix, skipped)
|
|
388
|
+
|
|
389
|
+
_console.print(tbl)
|
|
390
|
+
_console.print()
|
|
391
|
+
|
|
392
|
+
if result.missing_count == 0:
|
|
393
|
+
_console.print(
|
|
394
|
+
f"[green bold]All {result.total} permissions available.[/green bold] "
|
|
395
|
+
"cloudleak can run all rules in this region."
|
|
396
|
+
)
|
|
397
|
+
sys.exit(0)
|
|
398
|
+
else:
|
|
399
|
+
affected = sorted(result.affected_rule_ids)
|
|
400
|
+
_console.print(
|
|
401
|
+
f"[yellow]{result.ok_count}/{result.total}[/yellow] permissions available. "
|
|
402
|
+
f"[red]{result.missing_count} missing.[/red]"
|
|
403
|
+
)
|
|
404
|
+
if affected:
|
|
405
|
+
_console.print(
|
|
406
|
+
f"[red]{len(affected)} rule(s)[/red] will be SKIPPED: {', '.join(affected)}"
|
|
407
|
+
)
|
|
408
|
+
sys.exit(1)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ─── prices ────────────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@main.group()
|
|
415
|
+
def prices() -> None:
|
|
416
|
+
"""Manage the offline pricing cache."""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@prices.command(name="update")
|
|
420
|
+
@click.option("--profile", default=None, help="AWS named profile to use.")
|
|
421
|
+
def prices_update(profile: str | None) -> None:
|
|
422
|
+
"""Fetch current AWS prices and write to the local cache (~/.cloudleak/prices.json)."""
|
|
423
|
+
from pathlib import Path
|
|
424
|
+
|
|
425
|
+
from cloudleak.auth.session import CloudLeakAuthError, get_session
|
|
426
|
+
from cloudleak.config.loader import load_config
|
|
427
|
+
from cloudleak.pricing.update import run_update
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
session = get_session(profile)
|
|
431
|
+
except CloudLeakAuthError as exc:
|
|
432
|
+
_err.print(f"[red]Auth error:[/red] {exc}")
|
|
433
|
+
sys.exit(2)
|
|
434
|
+
|
|
435
|
+
cfg = load_config()
|
|
436
|
+
cache_path = Path(cfg.pricing.get("cache_file", "~/.cloudleak/prices.json")).expanduser()
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
run_update(session, cache_path)
|
|
440
|
+
except SystemExit:
|
|
441
|
+
raise
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
_err.print(f"[red]Prices update failed:[/red] {exc}")
|
|
444
|
+
sys.exit(1)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ─── rules ─────────────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@main.group()
|
|
451
|
+
def rules() -> None:
|
|
452
|
+
"""List and describe scan rules."""
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@rules.command(name="list")
|
|
456
|
+
@click.option("--service", "group", default=None, help="Filter by group (e.g. ec2, rds, s3).")
|
|
457
|
+
@click.option(
|
|
458
|
+
"--severity",
|
|
459
|
+
type=click.Choice(["HIGH", "MEDIUM", "LOW"]),
|
|
460
|
+
default=None,
|
|
461
|
+
help="Minimum severity to include.",
|
|
462
|
+
)
|
|
463
|
+
def rules_list(group: str | None, severity: str | None) -> None:
|
|
464
|
+
"""List all available scan rules."""
|
|
465
|
+
from rich.table import Table
|
|
466
|
+
|
|
467
|
+
from cloudleak.rules import get_rules
|
|
468
|
+
|
|
469
|
+
groups = [group.lower()] if group else None
|
|
470
|
+
ruleset = get_rules(groups=groups, severity=severity)
|
|
471
|
+
|
|
472
|
+
if not ruleset:
|
|
473
|
+
_console.print("[yellow]No rules registered yet (Epic 4+).[/yellow]")
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
tbl = Table(title="CloudLeak Rules", show_lines=False)
|
|
477
|
+
tbl.add_column("ID", style="bold cyan", no_wrap=True)
|
|
478
|
+
tbl.add_column("Severity", no_wrap=True)
|
|
479
|
+
tbl.add_column("Service", no_wrap=True)
|
|
480
|
+
tbl.add_column("Name")
|
|
481
|
+
tbl.add_column("Description")
|
|
482
|
+
|
|
483
|
+
_sev_colour = {"HIGH": "red", "MEDIUM": "yellow", "LOW": "blue"}
|
|
484
|
+
for rule in sorted(ruleset, key=lambda r: (r.service, r.rule_id)):
|
|
485
|
+
colour = _sev_colour.get(rule.severity.value, "white")
|
|
486
|
+
tbl.add_row(
|
|
487
|
+
rule.rule_id,
|
|
488
|
+
f"[{colour}]{rule.severity.value}[/{colour}]",
|
|
489
|
+
rule.service,
|
|
490
|
+
rule.name,
|
|
491
|
+
rule.description,
|
|
492
|
+
)
|
|
493
|
+
_console.print(tbl)
|
|
494
|
+
_console.print(f"[dim]{len(ruleset)} rule(s)[/dim]")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@rules.command(name="describe")
|
|
498
|
+
@click.argument("rule_id")
|
|
499
|
+
def rules_describe(rule_id: str) -> None:
|
|
500
|
+
"""Show full specification for a rule (e.g. EC2-001)."""
|
|
501
|
+
from rich.table import Table
|
|
502
|
+
|
|
503
|
+
from cloudleak.rules import ALL_RULES
|
|
504
|
+
|
|
505
|
+
target = next(
|
|
506
|
+
(r for r in ALL_RULES if r.rule_id.upper() == rule_id.upper()), None
|
|
507
|
+
)
|
|
508
|
+
if target is None:
|
|
509
|
+
_err.print(f"[red]Rule not found:[/red] {rule_id}")
|
|
510
|
+
sys.exit(1)
|
|
511
|
+
|
|
512
|
+
_sev_colour = {"HIGH": "red", "MEDIUM": "yellow", "LOW": "blue"}
|
|
513
|
+
colour = _sev_colour.get(target.severity.value, "white")
|
|
514
|
+
|
|
515
|
+
tbl = Table(show_header=False, box=None, padding=(0, 1))
|
|
516
|
+
tbl.add_column("Field", style="bold", no_wrap=True)
|
|
517
|
+
tbl.add_column("Value")
|
|
518
|
+
|
|
519
|
+
tbl.add_row("ID", target.rule_id)
|
|
520
|
+
tbl.add_row("Name", target.name)
|
|
521
|
+
tbl.add_row("Service", target.service)
|
|
522
|
+
tbl.add_row("Group", getattr(target, "group", target.service.lower()))
|
|
523
|
+
tbl.add_row("Severity", f"[{colour}]{target.severity.value}[/{colour}]")
|
|
524
|
+
tbl.add_row("Description", target.description)
|
|
525
|
+
tbl.add_row("Rationale", target.rationale)
|
|
526
|
+
tbl.add_row("Permissions", "\n".join(target.permissions))
|
|
527
|
+
tbl.add_row(
|
|
528
|
+
"Thresholds",
|
|
529
|
+
"\n".join(f"{k}: {v}" for k, v in target.thresholds.items()) or "—",
|
|
530
|
+
)
|
|
531
|
+
_console.print(tbl)
|
|
File without changes
|