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.
- cloud_audit/__init__.py +3 -0
- cloud_audit/__main__.py +5 -0
- cloud_audit/cli.py +229 -0
- cloud_audit/models.py +120 -0
- cloud_audit/providers/__init__.py +1 -0
- cloud_audit/providers/aws/__init__.py +5 -0
- cloud_audit/providers/aws/checks/__init__.py +1 -0
- cloud_audit/providers/aws/checks/ec2.py +132 -0
- cloud_audit/providers/aws/checks/eip.py +54 -0
- cloud_audit/providers/aws/checks/iam.py +194 -0
- cloud_audit/providers/aws/checks/rds.py +132 -0
- cloud_audit/providers/aws/checks/s3.py +145 -0
- cloud_audit/providers/aws/checks/vpc.py +183 -0
- cloud_audit/providers/aws/provider.py +62 -0
- cloud_audit/providers/base.py +29 -0
- cloud_audit/py.typed +0 -0
- cloud_audit/reports/__init__.py +1 -0
- cloud_audit/reports/html.py +39 -0
- cloud_audit/reports/templates/report.html.j2 +334 -0
- cloud_audit/scanner.py +73 -0
- cloud_audit-0.1.0.dist-info/METADATA +273 -0
- cloud_audit-0.1.0.dist-info/RECORD +25 -0
- cloud_audit-0.1.0.dist-info/WHEEL +4 -0
- cloud_audit-0.1.0.dist-info/entry_points.txt +2 -0
- cloud_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
cloud_audit/__init__.py
ADDED
cloud_audit/__main__.py
ADDED
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 @@
|
|
|
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
|