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.
Files changed (60) hide show
  1. cloudleak/__init__.py +6 -0
  2. cloudleak/auth/__init__.py +0 -0
  3. cloudleak/auth/session.py +53 -0
  4. cloudleak/cli/__init__.py +531 -0
  5. cloudleak/config/__init__.py +0 -0
  6. cloudleak/config/loader.py +187 -0
  7. cloudleak/output/__init__.py +8 -0
  8. cloudleak/output/console.py +266 -0
  9. cloudleak/output/csv_.py +44 -0
  10. cloudleak/output/executive.py +26 -0
  11. cloudleak/output/executive_model.py +319 -0
  12. cloudleak/output/html_.py +921 -0
  13. cloudleak/output/json_.py +76 -0
  14. cloudleak/output/templates/executive.html.j2 +506 -0
  15. cloudleak/pricing/__init__.py +0 -0
  16. cloudleak/pricing/cache.py +172 -0
  17. cloudleak/pricing/prices.json +4 -0
  18. cloudleak/pricing/update.py +381 -0
  19. cloudleak/rules/__init__.py +90 -0
  20. cloudleak/rules/aiml.py +1019 -0
  21. cloudleak/rules/analytics.py +939 -0
  22. cloudleak/rules/appsync.py +140 -0
  23. cloudleak/rules/batch.py +154 -0
  24. cloudleak/rules/cloudwatch.py +586 -0
  25. cloudleak/rules/codebuild.py +139 -0
  26. cloudleak/rules/codepipeline.py +135 -0
  27. cloudleak/rules/connect.py +123 -0
  28. cloudleak/rules/containers.py +754 -0
  29. cloudleak/rules/datasync.py +109 -0
  30. cloudleak/rules/desktop.py +318 -0
  31. cloudleak/rules/ebs.py +866 -0
  32. cloudleak/rules/ec2.py +1088 -0
  33. cloudleak/rules/gamelift.py +239 -0
  34. cloudleak/rules/governance.py +443 -0
  35. cloudleak/rules/grafana.py +119 -0
  36. cloudleak/rules/ivs.py +116 -0
  37. cloudleak/rules/lambda_.py +997 -0
  38. cloudleak/rules/lightsail.py +142 -0
  39. cloudleak/rules/messaging.py +420 -0
  40. cloudleak/rules/networking.py +1378 -0
  41. cloudleak/rules/prometheus.py +116 -0
  42. cloudleak/rules/rds.py +1542 -0
  43. cloudleak/rules/s3.py +905 -0
  44. cloudleak/rules/security.py +753 -0
  45. cloudleak/rules/transcoder.py +132 -0
  46. cloudleak/rules/transfer.py +123 -0
  47. cloudleak/scanner/__init__.py +0 -0
  48. cloudleak/scanner/cloudwatch.py +154 -0
  49. cloudleak/scanner/compute_optimizer.py +82 -0
  50. cloudleak/scanner/context.py +21 -0
  51. cloudleak/scanner/doctor.py +378 -0
  52. cloudleak/scanner/finding.py +70 -0
  53. cloudleak/scanner/orchestrator.py +167 -0
  54. cloudleak/scanner/rule.py +23 -0
  55. cloudleak-1.0.0.dist-info/METADATA +518 -0
  56. cloudleak-1.0.0.dist-info/RECORD +60 -0
  57. cloudleak-1.0.0.dist-info/WHEEL +5 -0
  58. cloudleak-1.0.0.dist-info/entry_points.txt +2 -0
  59. cloudleak-1.0.0.dist-info/licenses/LICENSE +21 -0
  60. cloudleak-1.0.0.dist-info/top_level.txt +1 -0
cloudleak/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version as _version
2
+
3
+ try:
4
+ __version__ = _version("cloudleak")
5
+ except PackageNotFoundError:
6
+ __version__ = "unknown"
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