cloud-audit 0.1.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.
@@ -0,0 +1,3 @@
1
+ """cloud-audit — Scan your cloud infrastructure for security, cost, and reliability issues."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m cloud_audit`."""
2
+
3
+ from cloud_audit.cli import app
4
+
5
+ app()
cloud_audit/cli.py ADDED
@@ -0,0 +1,229 @@
1
+ """CLI interface for cloud-audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from cloud_audit import __version__
13
+ from cloud_audit.models import Severity
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ from cloud_audit.models import ScanReport
19
+
20
+ app = typer.Typer(
21
+ name="cloud-audit",
22
+ help="Scan your cloud infrastructure for security, cost, and reliability issues.",
23
+ no_args_is_help=True,
24
+ )
25
+ console = Console()
26
+
27
+ SEVERITY_COLORS = {
28
+ Severity.CRITICAL: "bold red",
29
+ Severity.HIGH: "red",
30
+ Severity.MEDIUM: "yellow",
31
+ Severity.LOW: "cyan",
32
+ Severity.INFO: "dim",
33
+ }
34
+
35
+ SEVERITY_ICONS = {
36
+ Severity.CRITICAL: "\u2716",
37
+ Severity.HIGH: "\u2716",
38
+ Severity.MEDIUM: "\u26a0",
39
+ Severity.LOW: "\u25cb",
40
+ Severity.INFO: "\u2139",
41
+ }
42
+
43
+
44
+ def _print_summary(report: ScanReport) -> None:
45
+ """Print a rich summary of the scan results to the console."""
46
+ s = report.summary
47
+ all_errored = s.checks_errored > 0 and s.checks_passed == 0 and s.checks_failed == 0
48
+
49
+ # If all checks errored, show error banner instead of fake score
50
+ if all_errored:
51
+ console.print()
52
+ console.print(
53
+ Panel(
54
+ "[bold red]SCAN FAILED[/bold red]\n\nAll checks returned errors. No resources were scanned.",
55
+ title="[bold red]Error[/bold red]",
56
+ border_style="red",
57
+ width=60,
58
+ )
59
+ )
60
+
61
+ # Show error details
62
+ errored_results = [r for r in report.results if r.error]
63
+ if errored_results:
64
+ # Deduplicate error messages
65
+ unique_errors: dict[str, list[str]] = {}
66
+ for r in errored_results:
67
+ err = r.error or "Unknown error"
68
+ err_short = err.split("\n")[0][:120]
69
+ unique_errors.setdefault(err_short, []).append(r.check_id)
70
+
71
+ console.print("\n[bold]Errors:[/bold]")
72
+ for err_msg, check_ids in unique_errors.items():
73
+ console.print(f" [red]{err_msg}[/red]")
74
+ console.print(f" [dim]Affected checks: {', '.join(check_ids)}[/dim]\n")
75
+
76
+ # Common fix suggestions
77
+ console.print("[bold]Common fixes:[/bold]")
78
+ console.print(" 1. Check your AWS credentials: [cyan]aws sts get-caller-identity[/cyan]")
79
+ console.print(" 2. Refresh expired token: [cyan]aws sso login --profile <name>[/cyan]")
80
+ console.print(" 3. Verify region: [cyan]cloud-audit scan --regions eu-central-1[/cyan]")
81
+ console.print(" 4. Use a specific profile: [cyan]cloud-audit scan --profile <name>[/cyan]")
82
+ return
83
+
84
+ # Score panel
85
+ score = s.score
86
+ if score >= 80:
87
+ score_color = "green"
88
+ elif score >= 50:
89
+ score_color = "yellow"
90
+ else:
91
+ score_color = "red"
92
+
93
+ console.print()
94
+ console.print(
95
+ Panel(
96
+ f"[bold {score_color}]{score}[/bold {score_color}] / 100",
97
+ title="[bold]Health Score[/bold]",
98
+ border_style=score_color,
99
+ width=30,
100
+ )
101
+ )
102
+
103
+ # Summary table
104
+ table = Table(show_header=False, box=None, padding=(0, 2))
105
+ table.add_column(style="dim")
106
+ table.add_column()
107
+ table.add_row("Provider", report.provider.upper())
108
+ table.add_row("Account", report.account_id or "unknown")
109
+ table.add_row("Regions", ", ".join(report.regions) if report.regions else "default")
110
+ table.add_row("Duration", f"{report.duration_seconds}s")
111
+ table.add_row("Resources scanned", str(s.resources_scanned))
112
+ table.add_row("Checks passed", f"[green]{s.checks_passed}[/green]")
113
+ table.add_row("Checks failed", f"[red]{s.checks_failed}[/red]" if s.checks_failed else "0")
114
+ if s.checks_errored:
115
+ table.add_row("Checks errored", f"[yellow]{s.checks_errored}[/yellow]")
116
+ console.print(table)
117
+
118
+ # Show errors if any (partial failure)
119
+ if s.checks_errored:
120
+ errored_results = [r for r in report.results if r.error]
121
+ console.print(f"\n[yellow]Warning: {s.checks_errored} check(s) failed with errors:[/yellow]")
122
+ for r in errored_results:
123
+ err_short = (r.error or "Unknown")[:100]
124
+ console.print(f" [dim]{r.check_name}:[/dim] [yellow]{err_short}[/yellow]")
125
+
126
+ # Findings by severity
127
+ if s.by_severity:
128
+ console.print("\n[bold]Findings by severity:[/bold]")
129
+ for sev in Severity:
130
+ count = s.by_severity.get(sev, 0)
131
+ if count:
132
+ color = SEVERITY_COLORS[sev]
133
+ icon = SEVERITY_ICONS[sev]
134
+ console.print(f" [{color}]{icon} {sev.value.upper()}: {count}[/{color}]")
135
+
136
+ # Top findings
137
+ findings = report.all_findings
138
+ if findings:
139
+ severity_order = list(Severity)
140
+ findings_sorted = sorted(findings, key=lambda f: severity_order.index(f.severity))
141
+
142
+ shown = min(len(findings_sorted), 10)
143
+ console.print(f"\n[bold]Top findings ({shown} of {len(findings_sorted)}):[/bold]\n")
144
+
145
+ findings_table = Table(box=None, padding=(0, 1), show_header=True, header_style="bold")
146
+ findings_table.add_column("Sev", width=8)
147
+ findings_table.add_column("Region", width=14)
148
+ findings_table.add_column("Check")
149
+ findings_table.add_column("Resource")
150
+ findings_table.add_column("Title", max_width=60)
151
+
152
+ for f in findings_sorted[:10]:
153
+ sev_color = SEVERITY_COLORS[f.severity]
154
+ findings_table.add_row(
155
+ f"[{sev_color}]{f.severity.value.upper()}[/{sev_color}]",
156
+ f"[dim]{f.region or 'global'}[/dim]",
157
+ f.check_id,
158
+ f.resource_id[:40],
159
+ f.title[:60],
160
+ )
161
+
162
+ console.print(findings_table)
163
+
164
+ if len(findings_sorted) > 10:
165
+ remaining = len(findings_sorted) - 10
166
+ console.print(f"\n [dim]... and {remaining} more. See full report for details.[/dim]")
167
+ elif not s.checks_errored:
168
+ console.print("\n[bold green]No issues found. Your infrastructure looks great![/bold green]")
169
+
170
+
171
+ @app.command()
172
+ def scan(
173
+ provider: Annotated[str, typer.Option("--provider", "-p", help="Cloud provider")] = "aws",
174
+ profile: Annotated[str | None, typer.Option("--profile", help="AWS profile name")] = None,
175
+ regions: Annotated[str | None, typer.Option("--regions", "-r", help="Comma-separated regions, or 'all'")] = None,
176
+ categories: Annotated[
177
+ str | None, typer.Option("--categories", "-c", help="Filter: security,cost,reliability")
178
+ ] = None,
179
+ output: Annotated[Path | None, typer.Option("--output", "-o", help="Output file path (.html, .json)")] = None,
180
+ ) -> None:
181
+ """Scan cloud infrastructure and generate an audit report."""
182
+ from pathlib import Path as PathCls
183
+
184
+ from cloud_audit.scanner import run_scan
185
+
186
+ region_list = [r.strip() for r in regions.split(",")] if regions else None
187
+ category_list = [c.strip() for c in categories.split(",")] if categories else None
188
+
189
+ # Initialize provider
190
+ if provider == "aws":
191
+ from cloud_audit.providers.aws import AWSProvider
192
+
193
+ cloud_provider = AWSProvider(profile=profile, regions=region_list)
194
+ else:
195
+ console.print(f"[red]Provider '{provider}' is not supported yet. Available: aws[/red]")
196
+ raise typer.Exit(1)
197
+
198
+ # Run scan
199
+ report = run_scan(cloud_provider, categories=category_list)
200
+
201
+ # Print summary
202
+ _print_summary(report)
203
+
204
+ # Write output
205
+ if output:
206
+ out_path = PathCls(output) if not isinstance(output, PathCls) else output
207
+ suffix = out_path.suffix.lower()
208
+ if suffix == ".html":
209
+ from cloud_audit.reports.html import render_html
210
+
211
+ html = render_html(report)
212
+ out_path.write_text(html, encoding="utf-8")
213
+ console.print(f"\n[green]HTML report saved to {out_path}[/green]")
214
+ elif suffix == ".json":
215
+ out_path.write_text(report.model_dump_json(indent=2), encoding="utf-8")
216
+ console.print(f"\n[green]JSON report saved to {out_path}[/green]")
217
+ else:
218
+ console.print(f"[red]Unsupported output format: {suffix}. Use .html or .json[/red]")
219
+ raise typer.Exit(1)
220
+
221
+
222
+ @app.command()
223
+ def version() -> None:
224
+ """Show version."""
225
+ console.print(f"cloud-audit {__version__}")
226
+
227
+
228
+ if __name__ == "__main__":
229
+ app()
cloud_audit/models.py ADDED
@@ -0,0 +1,120 @@
1
+ """Core data models for cloud-audit findings and reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class Severity(str, Enum):
12
+ CRITICAL = "critical"
13
+ HIGH = "high"
14
+ MEDIUM = "medium"
15
+ LOW = "low"
16
+ INFO = "info"
17
+
18
+
19
+ class Category(str, Enum):
20
+ SECURITY = "security"
21
+ COST = "cost"
22
+ RELIABILITY = "reliability"
23
+ PERFORMANCE = "performance"
24
+
25
+
26
+ SEVERITY_SCORE = {
27
+ Severity.CRITICAL: 0,
28
+ Severity.HIGH: 5,
29
+ Severity.MEDIUM: 15,
30
+ Severity.LOW: 25,
31
+ Severity.INFO: 35,
32
+ }
33
+
34
+ SEVERITY_WEIGHT = {
35
+ Severity.CRITICAL: 20,
36
+ Severity.HIGH: 10,
37
+ Severity.MEDIUM: 5,
38
+ Severity.LOW: 2,
39
+ Severity.INFO: 0,
40
+ }
41
+
42
+
43
+ class Finding(BaseModel):
44
+ """A single audit finding — one issue detected in the infrastructure."""
45
+
46
+ check_id: str = Field(description="Unique check identifier, e.g. 'aws-iam-001'")
47
+ title: str = Field(description="Short human-readable title")
48
+ severity: Severity
49
+ category: Category
50
+ resource_type: str = Field(description="AWS resource type, e.g. 'AWS::IAM::User'")
51
+ resource_id: str = Field(description="Resource identifier (ARN, ID, or name)")
52
+ region: str = Field(default="global")
53
+ description: str = Field(description="What is wrong")
54
+ recommendation: str = Field(description="How to fix it")
55
+
56
+
57
+ class CheckResult(BaseModel):
58
+ """Result of running a single check — may produce 0..N findings."""
59
+
60
+ check_id: str
61
+ check_name: str
62
+ findings: list[Finding] = Field(default_factory=list)
63
+ resources_scanned: int = 0
64
+ error: str | None = None
65
+
66
+
67
+ class ScanSummary(BaseModel):
68
+ """Aggregated summary of a full scan."""
69
+
70
+ total_findings: int = 0
71
+ by_severity: dict[Severity, int] = Field(default_factory=dict)
72
+ by_category: dict[Category, int] = Field(default_factory=dict)
73
+ resources_scanned: int = 0
74
+ checks_passed: int = 0
75
+ checks_failed: int = 0
76
+ checks_errored: int = 0
77
+ score: int = Field(default=100, description="Overall health score 0-100")
78
+
79
+
80
+ class ScanReport(BaseModel):
81
+ """Complete scan report — the top-level output."""
82
+
83
+ provider: str
84
+ account_id: str = ""
85
+ regions: list[str] = Field(default_factory=list)
86
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
87
+ duration_seconds: float = 0.0
88
+ summary: ScanSummary = Field(default_factory=ScanSummary)
89
+ results: list[CheckResult] = Field(default_factory=list)
90
+
91
+ @property
92
+ def all_findings(self) -> list[Finding]:
93
+ findings: list[Finding] = []
94
+ for result in self.results:
95
+ findings.extend(result.findings)
96
+ return findings
97
+
98
+ def compute_summary(self) -> None:
99
+ findings = self.all_findings
100
+ self.summary.total_findings = len(findings)
101
+ self.summary.resources_scanned = sum(r.resources_scanned for r in self.results)
102
+ self.summary.checks_passed = sum(1 for r in self.results if not r.findings and not r.error)
103
+ self.summary.checks_failed = sum(1 for r in self.results if r.findings)
104
+ self.summary.checks_errored = sum(1 for r in self.results if r.error)
105
+
106
+ self.summary.by_severity = {}
107
+ for sev in Severity:
108
+ count = sum(1 for f in findings if f.severity == sev)
109
+ if count:
110
+ self.summary.by_severity[sev] = count
111
+
112
+ self.summary.by_category = {}
113
+ for cat in Category:
114
+ count = sum(1 for f in findings if f.category == cat)
115
+ if count:
116
+ self.summary.by_category[cat] = count
117
+
118
+ # Score: start at 100, subtract based on severity weights
119
+ penalty = sum(SEVERITY_WEIGHT[f.severity] for f in findings)
120
+ self.summary.score = max(0, 100 - penalty)
@@ -0,0 +1 @@
1
+ """Cloud providers for cloud-audit."""
@@ -0,0 +1,5 @@
1
+ """AWS provider for cloud-audit."""
2
+
3
+ from cloud_audit.providers.aws.provider import AWSProvider
4
+
5
+ __all__ = ["AWSProvider"]
@@ -0,0 +1 @@
1
+ """AWS checks registry."""
@@ -0,0 +1,132 @@
1
+ """EC2 security and cost checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import partial
6
+ from typing import TYPE_CHECKING
7
+
8
+ from cloud_audit.models import Category, CheckResult, Finding, Severity
9
+
10
+ if TYPE_CHECKING:
11
+ from cloud_audit.providers.aws.provider import AWSProvider
12
+ from cloud_audit.providers.base import CheckFn
13
+
14
+
15
+ def check_public_amis(provider: AWSProvider) -> CheckResult:
16
+ """Check for AMIs that are publicly shared."""
17
+ result = CheckResult(check_id="aws-ec2-001", check_name="Public AMIs")
18
+
19
+ try:
20
+ for region in provider.regions:
21
+ ec2 = provider.session.client("ec2", region_name=region)
22
+ images = ec2.describe_images(Owners=["self"])["Images"]
23
+ for image in images:
24
+ result.resources_scanned += 1
25
+ if image.get("Public", False):
26
+ result.findings.append(
27
+ Finding(
28
+ check_id="aws-ec2-001",
29
+ title=f"AMI '{image['ImageId']}' is publicly shared",
30
+ severity=Severity.HIGH,
31
+ category=Category.SECURITY,
32
+ resource_type="AWS::EC2::Image",
33
+ resource_id=image["ImageId"],
34
+ region=region,
35
+ description=f"AMI {image['ImageId']} ({image.get('Name', 'unnamed')}) is publicly accessible to all AWS accounts.",
36
+ recommendation="Make the AMI private unless public sharing is intentional.",
37
+ )
38
+ )
39
+ except Exception as e:
40
+ result.error = str(e)
41
+
42
+ return result
43
+
44
+
45
+ def check_unencrypted_volumes(provider: AWSProvider) -> CheckResult:
46
+ """Check for EBS volumes without encryption."""
47
+ result = CheckResult(check_id="aws-ec2-002", check_name="Unencrypted EBS volumes")
48
+
49
+ try:
50
+ for region in provider.regions:
51
+ ec2 = provider.session.client("ec2", region_name=region)
52
+ paginator = ec2.get_paginator("describe_volumes")
53
+ for page in paginator.paginate():
54
+ for volume in page["Volumes"]:
55
+ result.resources_scanned += 1
56
+ if not volume.get("Encrypted", False):
57
+ vol_id = volume["VolumeId"]
58
+ size = volume["Size"]
59
+ result.findings.append(
60
+ Finding(
61
+ check_id="aws-ec2-002",
62
+ title=f"EBS volume '{vol_id}' is not encrypted",
63
+ severity=Severity.MEDIUM,
64
+ category=Category.SECURITY,
65
+ resource_type="AWS::EC2::Volume",
66
+ resource_id=vol_id,
67
+ region=region,
68
+ description=f"Volume {vol_id} ({size} GiB) is not encrypted at rest.",
69
+ recommendation="Enable EBS default encryption in account settings and migrate existing volumes.",
70
+ )
71
+ )
72
+ except Exception as e:
73
+ result.error = str(e)
74
+
75
+ return result
76
+
77
+
78
+ def check_stopped_instances(provider: AWSProvider) -> CheckResult:
79
+ """Check for EC2 instances that have been stopped for more than 7 days."""
80
+ result = CheckResult(check_id="aws-ec2-003", check_name="Stopped EC2 instances (cost)")
81
+
82
+ try:
83
+ from datetime import datetime, timezone
84
+
85
+ datetime.now(timezone.utc)
86
+ for region in provider.regions:
87
+ ec2 = provider.session.client("ec2", region_name=region)
88
+ paginator = ec2.get_paginator("describe_instances")
89
+ for page in paginator.paginate(Filters=[{"Name": "instance-state-name", "Values": ["stopped"]}]):
90
+ for reservation in page["Reservations"]:
91
+ for instance in reservation["Instances"]:
92
+ result.resources_scanned += 1
93
+ instance_id = instance["InstanceId"]
94
+ instance_type = instance["InstanceType"]
95
+
96
+ # Check state transition time
97
+ instance.get("StateTransitionReason", "")
98
+ name_tag = next(
99
+ (t["Value"] for t in instance.get("Tags", []) if t["Key"] == "Name"),
100
+ "unnamed",
101
+ )
102
+
103
+ result.findings.append(
104
+ Finding(
105
+ check_id="aws-ec2-003",
106
+ title=f"EC2 instance '{name_tag}' ({instance_id}) is stopped",
107
+ severity=Severity.LOW,
108
+ category=Category.COST,
109
+ resource_type="AWS::EC2::Instance",
110
+ resource_id=instance_id,
111
+ region=region,
112
+ description=f"Instance {instance_id} ({instance_type}) is stopped. EBS volumes are still incurring charges.",
113
+ recommendation="Terminate the instance if no longer needed, or create an AMI and terminate to save on EBS costs.",
114
+ )
115
+ )
116
+ except Exception as e:
117
+ result.error = str(e)
118
+
119
+ return result
120
+
121
+
122
+ def get_checks(provider: AWSProvider) -> list[CheckFn]:
123
+ """Return all EC2 checks bound to the provider."""
124
+ checks: list[CheckFn] = [
125
+ partial(check_public_amis, provider),
126
+ partial(check_unencrypted_volumes, provider),
127
+ partial(check_stopped_instances, provider),
128
+ ]
129
+ for fn in checks:
130
+ fn.category = Category.SECURITY
131
+ checks[2].category = Category.COST
132
+ return checks
@@ -0,0 +1,54 @@
1
+ """Elastic IP cost checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import partial
6
+ from typing import TYPE_CHECKING
7
+
8
+ from cloud_audit.models import Category, CheckResult, Finding, Severity
9
+
10
+ if TYPE_CHECKING:
11
+ from cloud_audit.providers.aws.provider import AWSProvider
12
+ from cloud_audit.providers.base import CheckFn
13
+
14
+
15
+ def check_unattached_eips(provider: AWSProvider) -> CheckResult:
16
+ """Check for Elastic IPs that are not associated with any resource."""
17
+ result = CheckResult(check_id="aws-eip-001", check_name="Unattached Elastic IPs")
18
+
19
+ try:
20
+ for region in provider.regions:
21
+ ec2 = provider.session.client("ec2", region_name=region)
22
+ addresses = ec2.describe_addresses()["Addresses"]
23
+
24
+ for addr in addresses:
25
+ result.resources_scanned += 1
26
+ if not addr.get("AssociationId"):
27
+ eip = addr.get("PublicIp", addr.get("AllocationId", "unknown"))
28
+ result.findings.append(
29
+ Finding(
30
+ check_id="aws-eip-001",
31
+ title=f"Elastic IP {eip} is not attached to any resource",
32
+ severity=Severity.LOW,
33
+ category=Category.COST,
34
+ resource_type="AWS::EC2::EIP",
35
+ resource_id=addr.get("AllocationId", eip),
36
+ region=region,
37
+ description=f"Elastic IP {eip} is allocated but not associated. Unattached EIPs cost ~$3.65/month.",
38
+ recommendation="Release the Elastic IP if no longer needed, or associate it with an instance/NAT gateway.",
39
+ )
40
+ )
41
+ except Exception as e:
42
+ result.error = str(e)
43
+
44
+ return result
45
+
46
+
47
+ def get_checks(provider: AWSProvider) -> list[CheckFn]:
48
+ """Return all EIP checks bound to the provider."""
49
+ checks: list[CheckFn] = [
50
+ partial(check_unattached_eips, provider),
51
+ ]
52
+ for fn in checks:
53
+ fn.category = Category.COST
54
+ return checks