aws-inventory-manager 0.17.12__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.
- aws_inventory_manager-0.17.12.dist-info/LICENSE +21 -0
- aws_inventory_manager-0.17.12.dist-info/METADATA +1292 -0
- aws_inventory_manager-0.17.12.dist-info/RECORD +152 -0
- aws_inventory_manager-0.17.12.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.17.12.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.17.12.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/aws/__init__.py +11 -0
- src/aws/client.py +128 -0
- src/aws/credentials.py +191 -0
- src/aws/rate_limiter.py +177 -0
- src/cli/__init__.py +12 -0
- src/cli/config.py +130 -0
- src/cli/main.py +4046 -0
- src/cloudtrail/__init__.py +5 -0
- src/cloudtrail/query.py +642 -0
- src/config_service/__init__.py +21 -0
- src/config_service/collector.py +346 -0
- src/config_service/detector.py +256 -0
- src/config_service/resource_type_mapping.py +328 -0
- src/cost/__init__.py +5 -0
- src/cost/analyzer.py +226 -0
- src/cost/explorer.py +209 -0
- src/cost/reporter.py +237 -0
- src/delta/__init__.py +5 -0
- src/delta/calculator.py +206 -0
- src/delta/differ.py +185 -0
- src/delta/formatters.py +272 -0
- src/delta/models.py +154 -0
- src/delta/reporter.py +234 -0
- src/matching/__init__.py +6 -0
- src/matching/config.py +52 -0
- src/matching/normalizer.py +450 -0
- src/matching/prompts.py +33 -0
- src/models/__init__.py +21 -0
- src/models/config_diff.py +135 -0
- src/models/cost_report.py +87 -0
- src/models/deletion_operation.py +104 -0
- src/models/deletion_record.py +97 -0
- src/models/delta_report.py +122 -0
- src/models/efs_resource.py +80 -0
- src/models/elasticache_resource.py +90 -0
- src/models/group.py +318 -0
- src/models/inventory.py +133 -0
- src/models/protection_rule.py +123 -0
- src/models/report.py +288 -0
- src/models/resource.py +111 -0
- src/models/security_finding.py +102 -0
- src/models/snapshot.py +122 -0
- src/restore/__init__.py +20 -0
- src/restore/audit.py +175 -0
- src/restore/cleaner.py +461 -0
- src/restore/config.py +209 -0
- src/restore/deleter.py +976 -0
- src/restore/dependency.py +254 -0
- src/restore/safety.py +115 -0
- src/security/__init__.py +0 -0
- src/security/checks/__init__.py +0 -0
- src/security/checks/base.py +56 -0
- src/security/checks/ec2_checks.py +88 -0
- src/security/checks/elasticache_checks.py +149 -0
- src/security/checks/iam_checks.py +102 -0
- src/security/checks/rds_checks.py +140 -0
- src/security/checks/s3_checks.py +95 -0
- src/security/checks/secrets_checks.py +96 -0
- src/security/checks/sg_checks.py +142 -0
- src/security/cis_mapper.py +97 -0
- src/security/models.py +53 -0
- src/security/reporter.py +174 -0
- src/security/scanner.py +87 -0
- src/snapshot/__init__.py +6 -0
- src/snapshot/capturer.py +453 -0
- src/snapshot/filter.py +259 -0
- src/snapshot/inventory_storage.py +236 -0
- src/snapshot/report_formatter.py +250 -0
- src/snapshot/reporter.py +189 -0
- src/snapshot/resource_collectors/__init__.py +5 -0
- src/snapshot/resource_collectors/apigateway.py +140 -0
- src/snapshot/resource_collectors/backup.py +136 -0
- src/snapshot/resource_collectors/base.py +81 -0
- src/snapshot/resource_collectors/cloudformation.py +55 -0
- src/snapshot/resource_collectors/cloudwatch.py +109 -0
- src/snapshot/resource_collectors/codebuild.py +69 -0
- src/snapshot/resource_collectors/codepipeline.py +82 -0
- src/snapshot/resource_collectors/dynamodb.py +65 -0
- src/snapshot/resource_collectors/ec2.py +240 -0
- src/snapshot/resource_collectors/ecs.py +215 -0
- src/snapshot/resource_collectors/efs_collector.py +102 -0
- src/snapshot/resource_collectors/eks.py +200 -0
- src/snapshot/resource_collectors/elasticache_collector.py +79 -0
- src/snapshot/resource_collectors/elb.py +126 -0
- src/snapshot/resource_collectors/eventbridge.py +156 -0
- src/snapshot/resource_collectors/glue.py +199 -0
- src/snapshot/resource_collectors/iam.py +188 -0
- src/snapshot/resource_collectors/kms.py +111 -0
- src/snapshot/resource_collectors/lambda_func.py +139 -0
- src/snapshot/resource_collectors/rds.py +109 -0
- src/snapshot/resource_collectors/route53.py +86 -0
- src/snapshot/resource_collectors/s3.py +105 -0
- src/snapshot/resource_collectors/secretsmanager.py +70 -0
- src/snapshot/resource_collectors/sns.py +68 -0
- src/snapshot/resource_collectors/sqs.py +82 -0
- src/snapshot/resource_collectors/ssm.py +160 -0
- src/snapshot/resource_collectors/stepfunctions.py +74 -0
- src/snapshot/resource_collectors/vpcendpoints.py +79 -0
- src/snapshot/resource_collectors/waf.py +159 -0
- src/snapshot/storage.py +351 -0
- src/storage/__init__.py +21 -0
- src/storage/audit_store.py +419 -0
- src/storage/database.py +294 -0
- src/storage/group_store.py +763 -0
- src/storage/inventory_store.py +320 -0
- src/storage/resource_store.py +416 -0
- src/storage/schema.py +339 -0
- src/storage/snapshot_store.py +363 -0
- src/utils/__init__.py +12 -0
- src/utils/export.py +305 -0
- src/utils/hash.py +60 -0
- src/utils/logging.py +63 -0
- src/utils/pagination.py +41 -0
- src/utils/paths.py +51 -0
- src/utils/progress.py +41 -0
- src/utils/unsupported_resources.py +306 -0
- src/web/__init__.py +5 -0
- src/web/app.py +97 -0
- src/web/dependencies.py +69 -0
- src/web/routes/__init__.py +1 -0
- src/web/routes/api/__init__.py +18 -0
- src/web/routes/api/charts.py +156 -0
- src/web/routes/api/cleanup.py +186 -0
- src/web/routes/api/filters.py +253 -0
- src/web/routes/api/groups.py +305 -0
- src/web/routes/api/inventories.py +80 -0
- src/web/routes/api/queries.py +202 -0
- src/web/routes/api/resources.py +393 -0
- src/web/routes/api/snapshots.py +314 -0
- src/web/routes/api/views.py +260 -0
- src/web/routes/pages.py +198 -0
- src/web/services/__init__.py +1 -0
- src/web/templates/base.html +955 -0
- src/web/templates/components/navbar.html +31 -0
- src/web/templates/components/sidebar.html +104 -0
- src/web/templates/pages/audit_logs.html +86 -0
- src/web/templates/pages/cleanup.html +279 -0
- src/web/templates/pages/dashboard.html +227 -0
- src/web/templates/pages/diff.html +175 -0
- src/web/templates/pages/error.html +30 -0
- src/web/templates/pages/groups.html +721 -0
- src/web/templates/pages/queries.html +246 -0
- src/web/templates/pages/resources.html +2429 -0
- src/web/templates/pages/snapshot_detail.html +271 -0
- src/web/templates/pages/snapshots.html +429 -0
src/models/report.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for snapshot resource reporting.
|
|
3
|
+
|
|
4
|
+
This module defines the data structures used for generating and displaying
|
|
5
|
+
snapshot resource reports, including metadata, summaries, filtered views,
|
|
6
|
+
and detailed resource information.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Dict, List, Literal, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SnapshotMetadata:
|
|
18
|
+
"""
|
|
19
|
+
Snapshot identification information for report header.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: Snapshot name (e.g., "baseline-2025-01-29")
|
|
23
|
+
created_at: Snapshot creation timestamp
|
|
24
|
+
account_id: AWS account ID
|
|
25
|
+
regions: List of AWS regions included in snapshot
|
|
26
|
+
inventory_name: Parent inventory name
|
|
27
|
+
total_resource_count: Total number of resources in snapshot
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
created_at: datetime
|
|
32
|
+
account_id: str
|
|
33
|
+
regions: List[str]
|
|
34
|
+
inventory_name: str
|
|
35
|
+
total_resource_count: int
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def region_summary(self) -> str:
|
|
39
|
+
"""Human-readable region list (e.g., 'us-east-1, us-west-2 (2 regions)')."""
|
|
40
|
+
if len(self.regions) <= 3:
|
|
41
|
+
return ", ".join(self.regions)
|
|
42
|
+
else:
|
|
43
|
+
return f"{', '.join(self.regions[:3])} ... ({len(self.regions)} regions)"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ResourceSummary:
|
|
48
|
+
"""
|
|
49
|
+
Aggregated resource counts for summary view.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
total_count: Total number of resources
|
|
53
|
+
by_service: Count per AWS service (e.g., {"EC2": 100, "S3": 50})
|
|
54
|
+
by_region: Count per AWS region (e.g., {"us-east-1": 80, "us-west-2": 70})
|
|
55
|
+
by_type: Count per resource type (e.g., {"AWS::EC2::Instance": 50})
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
total_count: int = 0
|
|
59
|
+
by_service: Dict[str, int] = field(default_factory=dict)
|
|
60
|
+
by_region: Dict[str, int] = field(default_factory=dict)
|
|
61
|
+
by_type: Dict[str, int] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def service_count(self) -> int:
|
|
65
|
+
"""Number of distinct AWS services."""
|
|
66
|
+
return len(self.by_service)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def region_count(self) -> int:
|
|
70
|
+
"""Number of distinct AWS regions."""
|
|
71
|
+
return len(self.by_region)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def type_count(self) -> int:
|
|
75
|
+
"""Number of distinct resource types."""
|
|
76
|
+
return len(self.by_type)
|
|
77
|
+
|
|
78
|
+
def top_services(self, limit: int = 5) -> List[Tuple[str, int]]:
|
|
79
|
+
"""Return top N services by resource count."""
|
|
80
|
+
return sorted(self.by_service.items(), key=lambda x: x[1], reverse=True)[:limit]
|
|
81
|
+
|
|
82
|
+
def top_regions(self, limit: int = 5) -> List[Tuple[str, int]]:
|
|
83
|
+
"""Return top N regions by resource count."""
|
|
84
|
+
return sorted(self.by_region.items(), key=lambda x: x[1], reverse=True)[:limit]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class FilteredResource:
|
|
89
|
+
"""
|
|
90
|
+
Minimal resource info for filtered view (non-detailed).
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
arn: AWS Resource Name (unique identifier)
|
|
94
|
+
resource_type: CloudFormation resource type (e.g., "AWS::EC2::Instance")
|
|
95
|
+
name: Resource name or identifier
|
|
96
|
+
region: AWS region (e.g., "us-east-1")
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
arn: str
|
|
100
|
+
resource_type: str
|
|
101
|
+
name: str
|
|
102
|
+
region: str
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def service(self) -> str:
|
|
106
|
+
"""Extract service name from resource type (e.g., "EC2" from "AWS::EC2::Instance")."""
|
|
107
|
+
parts = self.resource_type.split("::")
|
|
108
|
+
return parts[1] if len(parts) >= 2 else "Unknown"
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def short_type(self) -> str:
|
|
112
|
+
"""Short resource type (e.g., "Instance" from "AWS::EC2::Instance")."""
|
|
113
|
+
parts = self.resource_type.split("::")
|
|
114
|
+
return parts[-1] if parts else self.resource_type
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class DetailedResource:
|
|
119
|
+
"""
|
|
120
|
+
Full resource details for detailed view.
|
|
121
|
+
|
|
122
|
+
Attributes:
|
|
123
|
+
arn: AWS Resource Name
|
|
124
|
+
resource_type: CloudFormation resource type
|
|
125
|
+
name: Resource name or identifier
|
|
126
|
+
region: AWS region
|
|
127
|
+
tags: Key-value tag pairs
|
|
128
|
+
created_at: Resource creation timestamp (if available)
|
|
129
|
+
config_hash: Configuration hash for change detection
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
arn: str
|
|
133
|
+
resource_type: str
|
|
134
|
+
name: str
|
|
135
|
+
region: str
|
|
136
|
+
tags: Dict[str, str]
|
|
137
|
+
created_at: Optional[datetime]
|
|
138
|
+
config_hash: str
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def service(self) -> str:
|
|
142
|
+
"""Extract service name from resource type."""
|
|
143
|
+
parts = self.resource_type.split("::")
|
|
144
|
+
return parts[1] if len(parts) >= 2 else "Unknown"
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def age_days(self) -> Optional[int]:
|
|
148
|
+
"""Calculate resource age in days (if creation date available)."""
|
|
149
|
+
if self.created_at:
|
|
150
|
+
from datetime import timezone
|
|
151
|
+
|
|
152
|
+
# Handle string dates (convert to datetime)
|
|
153
|
+
created_at = self.created_at
|
|
154
|
+
if isinstance(created_at, str):
|
|
155
|
+
try:
|
|
156
|
+
created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
157
|
+
except ValueError:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
# Ensure we have a datetime object
|
|
161
|
+
if not isinstance(created_at, datetime):
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
# Ensure we're comparing timezone-aware datetimes
|
|
165
|
+
now_utc = datetime.now(timezone.utc)
|
|
166
|
+
created = created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc)
|
|
167
|
+
return (now_utc - created).days
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def tag_count(self) -> int:
|
|
172
|
+
"""Number of tags applied to resource."""
|
|
173
|
+
return len(self.tags)
|
|
174
|
+
|
|
175
|
+
def has_tag(self, key: str, value: Optional[str] = None) -> bool:
|
|
176
|
+
"""Check if resource has specific tag (optionally with value)."""
|
|
177
|
+
if key not in self.tags:
|
|
178
|
+
return False
|
|
179
|
+
if value is not None:
|
|
180
|
+
return self.tags[key] == value
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class FilterCriteria:
|
|
186
|
+
"""
|
|
187
|
+
Filter specification for narrowing report results.
|
|
188
|
+
|
|
189
|
+
Attributes:
|
|
190
|
+
resource_types: List of resource types to include (flexible matching)
|
|
191
|
+
regions: List of AWS regions to include (exact matching)
|
|
192
|
+
match_mode: Matching strategy ("flexible" or "exact")
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
resource_types: Optional[List[str]] = None
|
|
196
|
+
regions: Optional[List[str]] = None
|
|
197
|
+
match_mode: Literal["flexible", "exact"] = "flexible"
|
|
198
|
+
|
|
199
|
+
def __post_init__(self) -> None:
|
|
200
|
+
"""Normalize filter values (lowercase for case-insensitive matching)."""
|
|
201
|
+
if self.resource_types:
|
|
202
|
+
self.resource_types = [rt.lower() for rt in self.resource_types]
|
|
203
|
+
if self.regions:
|
|
204
|
+
self.regions = [r.lower() for r in self.regions]
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def has_filters(self) -> bool:
|
|
208
|
+
"""Check if any filters are applied."""
|
|
209
|
+
return bool(self.resource_types or self.regions)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def filter_count(self) -> int:
|
|
213
|
+
"""Total number of filter criteria."""
|
|
214
|
+
count = 0
|
|
215
|
+
if self.resource_types:
|
|
216
|
+
count += len(self.resource_types)
|
|
217
|
+
if self.regions:
|
|
218
|
+
count += len(self.regions)
|
|
219
|
+
return count
|
|
220
|
+
|
|
221
|
+
def matches_resource(self, resource: FilteredResource) -> bool:
|
|
222
|
+
"""
|
|
223
|
+
Check if resource matches filter criteria.
|
|
224
|
+
|
|
225
|
+
Uses flexible matching for resource types (see research.md Task 2).
|
|
226
|
+
Uses exact matching for regions (case-insensitive).
|
|
227
|
+
"""
|
|
228
|
+
# Region filter (exact match, case-insensitive)
|
|
229
|
+
if self.regions and resource.region.lower() not in self.regions:
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
# Resource type filter (flexible matching)
|
|
233
|
+
if self.resource_types:
|
|
234
|
+
type_match = any(
|
|
235
|
+
self._match_resource_type(resource.resource_type, filter_type) for filter_type in self.resource_types
|
|
236
|
+
)
|
|
237
|
+
if not type_match:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
def _match_resource_type(self, resource_type: str, filter_value: str) -> bool:
|
|
243
|
+
"""Three-tier matching: exact → prefix → contains (see research.md)."""
|
|
244
|
+
resource_lower = resource_type.lower()
|
|
245
|
+
filter_lower = filter_value.lower()
|
|
246
|
+
|
|
247
|
+
# Tier 1: Exact match
|
|
248
|
+
if resource_lower == filter_lower:
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
# Tier 2: Service prefix match
|
|
252
|
+
service_prefix = f"aws::{filter_lower}::"
|
|
253
|
+
if resource_lower.startswith(service_prefix):
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
# Tier 3: Contains match
|
|
257
|
+
if filter_lower in resource_lower:
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class ResourceReport:
|
|
265
|
+
"""
|
|
266
|
+
Complete report data structure.
|
|
267
|
+
|
|
268
|
+
Attributes:
|
|
269
|
+
snapshot_metadata: Information about the snapshot being reported
|
|
270
|
+
summary: Aggregated resource counts by service/region/type
|
|
271
|
+
filtered_resources: Minimal resource list (if filtering applied)
|
|
272
|
+
detailed_resources: Full resource details (if detailed view requested)
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
snapshot_metadata: SnapshotMetadata
|
|
276
|
+
summary: ResourceSummary
|
|
277
|
+
filtered_resources: Optional[List[FilteredResource]] = None
|
|
278
|
+
detailed_resources: Optional[List[DetailedResource]] = None
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def has_filters(self) -> bool:
|
|
282
|
+
"""Check if report includes filtered results."""
|
|
283
|
+
return self.filtered_resources is not None
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def has_details(self) -> bool:
|
|
287
|
+
"""Check if report includes detailed resource info."""
|
|
288
|
+
return self.detailed_resources is not None
|
src/models/resource.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Resource data model representing a single AWS resource."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Resource:
|
|
11
|
+
"""Represents a single AWS resource captured in a snapshot."""
|
|
12
|
+
|
|
13
|
+
arn: str
|
|
14
|
+
resource_type: str
|
|
15
|
+
name: str
|
|
16
|
+
region: str
|
|
17
|
+
config_hash: str
|
|
18
|
+
raw_config: Optional[Dict[str, Any]] = None # Optional for backward compatibility with v1.0 snapshots
|
|
19
|
+
tags: Dict[str, str] = field(default_factory=dict)
|
|
20
|
+
created_at: Optional[datetime] = None
|
|
21
|
+
source: str = "direct_api" # Collection source: "config" or "direct_api"
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
24
|
+
"""Convert resource to dictionary for serialization.
|
|
25
|
+
|
|
26
|
+
Note: raw_config is included in v1.1+ snapshots for drift analysis support.
|
|
27
|
+
"""
|
|
28
|
+
return {
|
|
29
|
+
"arn": self.arn,
|
|
30
|
+
"type": self.resource_type,
|
|
31
|
+
"name": self.name,
|
|
32
|
+
"region": self.region,
|
|
33
|
+
"tags": self.tags,
|
|
34
|
+
"config_hash": self.config_hash,
|
|
35
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
36
|
+
"raw_config": self.raw_config if self.raw_config is not None else {},
|
|
37
|
+
"source": self.source,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Resource":
|
|
42
|
+
"""Create resource from dictionary.
|
|
43
|
+
|
|
44
|
+
Supports both v1.0 (without raw_config) and v1.1+ (with raw_config) snapshots.
|
|
45
|
+
"""
|
|
46
|
+
created_at = None
|
|
47
|
+
if data.get("created_at"):
|
|
48
|
+
if isinstance(data["created_at"], str):
|
|
49
|
+
created_at = datetime.fromisoformat(data["created_at"])
|
|
50
|
+
else:
|
|
51
|
+
created_at = data["created_at"] # Already a datetime
|
|
52
|
+
|
|
53
|
+
return cls(
|
|
54
|
+
arn=data["arn"],
|
|
55
|
+
resource_type=data["type"],
|
|
56
|
+
name=data["name"],
|
|
57
|
+
region=data["region"],
|
|
58
|
+
config_hash=data["config_hash"],
|
|
59
|
+
raw_config=data.get("raw_config"), # Optional for backward compatibility
|
|
60
|
+
tags=data.get("tags", {}),
|
|
61
|
+
created_at=created_at,
|
|
62
|
+
source=data.get("source", "direct_api"), # Default for older snapshots
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def validate(self) -> bool:
|
|
66
|
+
"""Validate resource data integrity.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if valid, raises ValueError if invalid
|
|
70
|
+
"""
|
|
71
|
+
# Validate ARN format
|
|
72
|
+
arn_pattern = r"^arn:aws:[a-z0-9-]+:[a-z0-9-]*:[0-9]*:.*$"
|
|
73
|
+
if not re.match(arn_pattern, self.arn):
|
|
74
|
+
raise ValueError(f"Invalid ARN format: {self.arn}")
|
|
75
|
+
|
|
76
|
+
# Validate config_hash is 64-character hex string (SHA256)
|
|
77
|
+
if not re.match(r"^[a-fA-F0-9]{64}$", self.config_hash):
|
|
78
|
+
raise ValueError(f"Invalid config_hash: {self.config_hash}. Must be 64-character SHA256 hex string.")
|
|
79
|
+
|
|
80
|
+
# Validate region format
|
|
81
|
+
valid_regions = ["global"] + [
|
|
82
|
+
"us-east-1",
|
|
83
|
+
"us-east-2",
|
|
84
|
+
"us-west-1",
|
|
85
|
+
"us-west-2",
|
|
86
|
+
"eu-west-1",
|
|
87
|
+
"eu-west-2",
|
|
88
|
+
"eu-west-3",
|
|
89
|
+
"eu-central-1",
|
|
90
|
+
"ap-southeast-1",
|
|
91
|
+
"ap-southeast-2",
|
|
92
|
+
"ap-northeast-1",
|
|
93
|
+
"ap-northeast-2",
|
|
94
|
+
"ca-central-1",
|
|
95
|
+
"sa-east-1",
|
|
96
|
+
"ap-south-1",
|
|
97
|
+
]
|
|
98
|
+
# Basic validation - starts with region pattern or is 'global'
|
|
99
|
+
if self.region != "global" and not any(self.region.startswith(r[:6]) for r in valid_regions if r != "global"):
|
|
100
|
+
# Allow it anyway - AWS adds new regions regularly
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def service(self) -> str:
|
|
107
|
+
"""Extract service name from resource type.
|
|
108
|
+
|
|
109
|
+
Example: 'iam:role' -> 'iam'
|
|
110
|
+
"""
|
|
111
|
+
return self.resource_type.split(":")[0] if ":" in self.resource_type else self.resource_type
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Security finding model for representing detected security issues in AWS resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(Enum):
|
|
11
|
+
"""Security finding severity levels."""
|
|
12
|
+
|
|
13
|
+
CRITICAL = "critical"
|
|
14
|
+
HIGH = "high"
|
|
15
|
+
MEDIUM = "medium"
|
|
16
|
+
LOW = "low"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SecurityFinding:
|
|
21
|
+
"""Represents a detected security misconfiguration in an AWS resource.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
resource_arn: AWS ARN of the resource with the security issue
|
|
25
|
+
finding_type: Type of security issue (e.g., "public_s3_bucket", "open_security_group")
|
|
26
|
+
severity: Severity level of the finding
|
|
27
|
+
description: Human-readable description of the issue
|
|
28
|
+
remediation: Guidance on how to fix the issue
|
|
29
|
+
cis_control: Optional CIS AWS Foundations Benchmark control ID (e.g., "2.1.5")
|
|
30
|
+
metadata: Additional context-specific metadata
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
resource_arn: str
|
|
34
|
+
finding_type: str
|
|
35
|
+
severity: Severity
|
|
36
|
+
description: str
|
|
37
|
+
remediation: str
|
|
38
|
+
cis_control: Optional[str] = None
|
|
39
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
"""Validate SecurityFinding fields after initialization."""
|
|
43
|
+
# Validate ARN format (basic check)
|
|
44
|
+
if not self.resource_arn or not self.resource_arn.startswith("arn:"):
|
|
45
|
+
raise ValueError(f"Invalid ARN format: {self.resource_arn}")
|
|
46
|
+
|
|
47
|
+
# Validate severity is Severity enum
|
|
48
|
+
if not isinstance(self.severity, Severity):
|
|
49
|
+
raise ValueError(f"Invalid severity type: {type(self.severity)}. Must be Severity enum.")
|
|
50
|
+
|
|
51
|
+
# Validate required string fields are not empty
|
|
52
|
+
if not self.finding_type:
|
|
53
|
+
raise ValueError("finding_type cannot be empty")
|
|
54
|
+
if not self.description:
|
|
55
|
+
raise ValueError("description cannot be empty")
|
|
56
|
+
if not self.remediation:
|
|
57
|
+
raise ValueError("remediation cannot be empty")
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
60
|
+
"""Convert SecurityFinding to dictionary representation.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Dictionary with all finding attributes
|
|
64
|
+
"""
|
|
65
|
+
return {
|
|
66
|
+
"resource_arn": self.resource_arn,
|
|
67
|
+
"finding_type": self.finding_type,
|
|
68
|
+
"severity": self.severity.value,
|
|
69
|
+
"description": self.description,
|
|
70
|
+
"remediation": self.remediation,
|
|
71
|
+
"cis_control": self.cis_control,
|
|
72
|
+
"metadata": self.metadata or {},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_dict(cls, data: Dict[str, Any]) -> SecurityFinding:
|
|
77
|
+
"""Create SecurityFinding from dictionary representation.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
data: Dictionary with finding attributes
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
SecurityFinding instance
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If severity value is invalid
|
|
87
|
+
"""
|
|
88
|
+
severity_str = data.get("severity", "").lower()
|
|
89
|
+
try:
|
|
90
|
+
severity = Severity(severity_str)
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise ValueError(f"Invalid severity value: {severity_str}")
|
|
93
|
+
|
|
94
|
+
return cls(
|
|
95
|
+
resource_arn=data["resource_arn"],
|
|
96
|
+
finding_type=data["finding_type"],
|
|
97
|
+
severity=severity,
|
|
98
|
+
description=data["description"],
|
|
99
|
+
remediation=data["remediation"],
|
|
100
|
+
cis_control=data.get("cis_control"),
|
|
101
|
+
metadata=data.get("metadata"),
|
|
102
|
+
)
|
src/models/snapshot.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Snapshot data model representing a point-in-time inventory of AWS resources."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Snapshot:
|
|
10
|
+
"""Represents a point-in-time inventory of AWS resources.
|
|
11
|
+
|
|
12
|
+
This serves as the baseline reference for delta tracking and cost analysis.
|
|
13
|
+
|
|
14
|
+
Schema Versions:
|
|
15
|
+
- v1.0: Basic snapshot with config_hash only
|
|
16
|
+
- v1.1: Added raw_config for drift analysis support
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
created_at: datetime
|
|
21
|
+
account_id: str
|
|
22
|
+
regions: List[str]
|
|
23
|
+
resources: List[Any] # List[Resource] - avoiding circular import
|
|
24
|
+
is_active: bool = True
|
|
25
|
+
resource_count: int = 0
|
|
26
|
+
service_counts: Dict[str, int] = field(default_factory=dict)
|
|
27
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
filters_applied: Optional[Dict[str, Any]] = None
|
|
29
|
+
total_resources_before_filter: Optional[int] = None
|
|
30
|
+
inventory_name: str = "default" # Name of inventory this snapshot belongs to
|
|
31
|
+
schema_version: str = "1.1" # Schema version for forward/backward compatibility
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
"""Calculate derived fields after initialization."""
|
|
35
|
+
if self.resource_count == 0:
|
|
36
|
+
self.resource_count = len(self.resources)
|
|
37
|
+
|
|
38
|
+
if not self.service_counts:
|
|
39
|
+
self._calculate_service_counts()
|
|
40
|
+
|
|
41
|
+
def _calculate_service_counts(self) -> None:
|
|
42
|
+
"""Calculate resource counts by service type."""
|
|
43
|
+
counts: Dict[str, int] = {}
|
|
44
|
+
for resource in self.resources:
|
|
45
|
+
service = resource.resource_type.split(":")[0] if ":" in resource.resource_type else resource.resource_type
|
|
46
|
+
counts[service] = counts.get(service, 0) + 1
|
|
47
|
+
self.service_counts = counts
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
50
|
+
"""Convert snapshot to dictionary for serialization."""
|
|
51
|
+
return {
|
|
52
|
+
"schema_version": self.schema_version,
|
|
53
|
+
"name": self.name,
|
|
54
|
+
"created_at": self.created_at.isoformat(),
|
|
55
|
+
"account_id": self.account_id,
|
|
56
|
+
"regions": self.regions,
|
|
57
|
+
"is_active": self.is_active,
|
|
58
|
+
"resource_count": self.resource_count,
|
|
59
|
+
"service_counts": self.service_counts,
|
|
60
|
+
"metadata": self.metadata,
|
|
61
|
+
"filters_applied": self.filters_applied,
|
|
62
|
+
"total_resources_before_filter": self.total_resources_before_filter,
|
|
63
|
+
"inventory_name": self.inventory_name,
|
|
64
|
+
"resources": [r.to_dict() for r in self.resources],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Snapshot":
|
|
69
|
+
"""Create snapshot from dictionary.
|
|
70
|
+
|
|
71
|
+
Supports both v1.0 and v1.1+ snapshot formats for backward compatibility.
|
|
72
|
+
|
|
73
|
+
Note: This requires Resource class to be imported at call time
|
|
74
|
+
to avoid circular imports.
|
|
75
|
+
"""
|
|
76
|
+
from .resource import Resource
|
|
77
|
+
|
|
78
|
+
# Handle created_at being either string or datetime (PyYAML can auto-parse)
|
|
79
|
+
created_at = data["created_at"]
|
|
80
|
+
if isinstance(created_at, str):
|
|
81
|
+
created_at = datetime.fromisoformat(created_at)
|
|
82
|
+
|
|
83
|
+
return cls(
|
|
84
|
+
name=data["name"],
|
|
85
|
+
created_at=created_at,
|
|
86
|
+
account_id=data["account_id"],
|
|
87
|
+
regions=data["regions"],
|
|
88
|
+
resources=[Resource.from_dict(r) for r in data["resources"]],
|
|
89
|
+
is_active=data.get("is_active", True),
|
|
90
|
+
resource_count=data.get("resource_count", 0),
|
|
91
|
+
service_counts=data.get("service_counts", {}),
|
|
92
|
+
metadata=data.get("metadata", {}),
|
|
93
|
+
filters_applied=data.get("filters_applied"),
|
|
94
|
+
total_resources_before_filter=data.get("total_resources_before_filter"),
|
|
95
|
+
inventory_name=data.get("inventory_name", "default"), # Default for backward compatibility
|
|
96
|
+
schema_version=data.get("schema_version", "1.0"), # Default to 1.0 for old snapshots
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def validate(self) -> bool:
|
|
100
|
+
"""Validate snapshot data integrity.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if valid, raises ValueError if invalid
|
|
104
|
+
"""
|
|
105
|
+
import re
|
|
106
|
+
|
|
107
|
+
# Validate name format (alphanumeric, hyphens, underscores)
|
|
108
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", self.name):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"Invalid snapshot name: {self.name}. "
|
|
111
|
+
f"Must contain only alphanumeric characters, hyphens, and underscores."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Validate account ID (12-digit string)
|
|
115
|
+
if not re.match(r"^\d{12}$", self.account_id):
|
|
116
|
+
raise ValueError(f"Invalid AWS account ID: {self.account_id}. Must be a 12-digit string.")
|
|
117
|
+
|
|
118
|
+
# Validate regions list is not empty
|
|
119
|
+
if not self.regions:
|
|
120
|
+
raise ValueError("Snapshot must include at least one AWS region.")
|
|
121
|
+
|
|
122
|
+
return True
|
src/restore/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Resource cleanup/restoration module.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to delete AWS resources created after a baseline
|
|
4
|
+
snapshot, with comprehensive safety protections.
|
|
5
|
+
|
|
6
|
+
Classes:
|
|
7
|
+
ResourceCleaner: Main orchestrator for restore operations
|
|
8
|
+
DependencyResolver: Dependency graph construction and deletion ordering
|
|
9
|
+
SafetyChecker: Protection rule evaluation
|
|
10
|
+
AuditStorage: Audit log storage and retrieval
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ResourceCleaner",
|
|
17
|
+
"DependencyResolver",
|
|
18
|
+
"SafetyChecker",
|
|
19
|
+
"AuditStorage",
|
|
20
|
+
]
|