stacksage 0.7.2__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.
- stacksage/ai/summarizer.py +49 -0
- stacksage/analyzer/analysis.py +468 -0
- stacksage/analyzer/detectors/__init__.py +72 -0
- stacksage/analyzer/detectors/architecture.py +1102 -0
- stacksage/analyzer/detectors/cdn.py +247 -0
- stacksage/analyzer/detectors/cloudwatch.py +263 -0
- stacksage/analyzer/detectors/dynamodb.py +185 -0
- stacksage/analyzer/detectors/ebs.py +642 -0
- stacksage/analyzer/detectors/ec2.py +628 -0
- stacksage/analyzer/detectors/elasticache.py +234 -0
- stacksage/analyzer/detectors/guardrails.py +234 -0
- stacksage/analyzer/detectors/network.py +815 -0
- stacksage/analyzer/detectors/posture.py +1082 -0
- stacksage/analyzer/detectors/rds.py +512 -0
- stacksage/analyzer/detectors/s3.py +54 -0
- stacksage/analyzer/detectors/tagging.py +136 -0
- stacksage/analyzer/evidence.py +234 -0
- stacksage/analyzer/metrics.py +235 -0
- stacksage/cli/cli.py +776 -0
- stacksage/cli/free_tier.py +82 -0
- stacksage/cli/logging_util.py +27 -0
- stacksage/cli/report_gen.py +816 -0
- stacksage/cli/telemetry.py +115 -0
- stacksage/config.py +313 -0
- stacksage/costs.py +237 -0
- stacksage/licensing.py +213 -0
- stacksage/pricing.py +526 -0
- stacksage/scanner/aws_scanner.py +966 -0
- stacksage/templates/report.html.jinja +1148 -0
- stacksage-0.7.2.dist-info/METADATA +148 -0
- stacksage-0.7.2.dist-info/RECORD +34 -0
- stacksage-0.7.2.dist-info/WHEEL +5 -0
- stacksage-0.7.2.dist-info/entry_points.txt +2 -0
- stacksage-0.7.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# stacksage/ai/summarizer.py
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def summarize_analysis(analysis: Dict[str, Any]) -> Dict[str, Any]:
|
|
6
|
+
"""
|
|
7
|
+
Input: full analysis object
|
|
8
|
+
Output: concise executive summary + top savings drivers
|
|
9
|
+
"""
|
|
10
|
+
findings: List[Dict[str, Any]] = analysis.get("findings", [])
|
|
11
|
+
total_savings = float(analysis.get("estimated_monthly_savings", 0) or 0)
|
|
12
|
+
total_cost = float(analysis.get("estimated_monthly_cost", 0) or 0)
|
|
13
|
+
# Top savings drivers
|
|
14
|
+
sorted_findings = sorted(
|
|
15
|
+
findings,
|
|
16
|
+
key=lambda f: float(f.get("estimated_monthly_savings_usd", 0) or 0),
|
|
17
|
+
reverse=True,
|
|
18
|
+
)
|
|
19
|
+
top = sorted_findings[:5]
|
|
20
|
+
drivers = [
|
|
21
|
+
{
|
|
22
|
+
"type": f.get("type"),
|
|
23
|
+
"id": f.get("id"),
|
|
24
|
+
"region": f.get("region"),
|
|
25
|
+
"savings": float(f.get("estimated_monthly_savings_usd", 0) or 0),
|
|
26
|
+
"recommended_action": f.get("recommended_action") or f.get("action"),
|
|
27
|
+
}
|
|
28
|
+
for f in top
|
|
29
|
+
]
|
|
30
|
+
summary_text = (
|
|
31
|
+
(
|
|
32
|
+
f"Potential monthly savings: ${total_savings:.2f}. "
|
|
33
|
+
f"Current cost (flagged resources): ${total_cost:.2f}. "
|
|
34
|
+
f"Primary drivers: "
|
|
35
|
+
+ ", ".join(
|
|
36
|
+
[f"{d['type']}({d['id']}): ${d['savings']:.2f}" for d in drivers]
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
if drivers
|
|
40
|
+
else (
|
|
41
|
+
f"Potential monthly savings: ${total_savings:.2f}. "
|
|
42
|
+
f"Current cost (flagged resources): ${total_cost:.2f}. "
|
|
43
|
+
"No high-impact drivers identified."
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return {
|
|
47
|
+
"text": summary_text,
|
|
48
|
+
"top_drivers": drivers,
|
|
49
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StackSage Analyzer module
|
|
3
|
+
|
|
4
|
+
Detects cloud waste and produces findings. Supports optional CloudWatch live-mode
|
|
5
|
+
with a bounded query budget and error provenance. Aggregates CloudWatch datapoints
|
|
6
|
+
as privacy-safe averages only; no object payloads are fetched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
from stacksage.analyzer.detectors.architecture import (
|
|
14
|
+
detect_ecs_to_fargate_migration_opportunities,
|
|
15
|
+
detect_fargate_spot_opportunities,
|
|
16
|
+
detect_lambda_graviton_migration_opportunities,
|
|
17
|
+
detect_rds_to_aurora_serverless_v2,
|
|
18
|
+
detect_serverless_migration_opportunities,
|
|
19
|
+
)
|
|
20
|
+
from stacksage.analyzer.detectors.cdn import (
|
|
21
|
+
detect_unused_cloudfront_distributions,
|
|
22
|
+
detect_unused_route53_hosted_zones,
|
|
23
|
+
)
|
|
24
|
+
from stacksage.analyzer.detectors.cloudwatch import (
|
|
25
|
+
detect_cloudwatch_logs_retention,
|
|
26
|
+
detect_overprovisioned_lambda,
|
|
27
|
+
)
|
|
28
|
+
from stacksage.analyzer.detectors.dynamodb import detect_unused_dynamodb_tables
|
|
29
|
+
|
|
30
|
+
# Import detectors from modular structure
|
|
31
|
+
from stacksage.analyzer.detectors.ebs import (
|
|
32
|
+
detect_ebs_overprovisioned_performance,
|
|
33
|
+
detect_gp2_to_gp3_migration,
|
|
34
|
+
detect_old_snapshots,
|
|
35
|
+
detect_snapshot_consolidation,
|
|
36
|
+
detect_unattached_ebs,
|
|
37
|
+
)
|
|
38
|
+
from stacksage.analyzer.detectors.ec2 import (
|
|
39
|
+
detect_ec2_generation_upgrades,
|
|
40
|
+
detect_idle_ec2,
|
|
41
|
+
detect_stopped_ec2,
|
|
42
|
+
)
|
|
43
|
+
from stacksage.analyzer.detectors.elasticache import detect_idle_elasticache_clusters
|
|
44
|
+
from stacksage.analyzer.detectors.guardrails import (
|
|
45
|
+
detect_anomaly_detection_guardrail,
|
|
46
|
+
detect_budgets_guardrail,
|
|
47
|
+
)
|
|
48
|
+
from stacksage.analyzer.detectors.network import (
|
|
49
|
+
detect_idle_elb,
|
|
50
|
+
detect_lb_empty_target_groups,
|
|
51
|
+
detect_nat_gateways,
|
|
52
|
+
detect_unused_eips,
|
|
53
|
+
)
|
|
54
|
+
from stacksage.analyzer.detectors.posture import detect_tier1_posture
|
|
55
|
+
from stacksage.analyzer.detectors.rds import detect_underutilized_rds
|
|
56
|
+
from stacksage.analyzer.detectors.s3 import detect_s3_lifecycle_suggestions
|
|
57
|
+
from stacksage.analyzer.detectors.tagging import detect_untagged_resources
|
|
58
|
+
|
|
59
|
+
# MetricBudget and cw_avg live in metrics.py to avoid circular imports.
|
|
60
|
+
# Re-exported here so existing callers (tests, cli) keep working.
|
|
61
|
+
from stacksage.analyzer.metrics import MetricBudget, cw_avg # noqa: F401
|
|
62
|
+
from stacksage.config import StackSageConfig
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
# ==============================================================================
|
|
67
|
+
# DETECTORS NOW IMPORTED FROM stacksage.analyzer.detectors.*
|
|
68
|
+
# All detector functions have been moved to modular files for better organization:
|
|
69
|
+
# - EBS detectors → detectors/ebs.py
|
|
70
|
+
# - EC2 detectors → detectors/ec2.py
|
|
71
|
+
# - RDS detectors → detectors/rds.py
|
|
72
|
+
# - Network detectors → detectors/network.py
|
|
73
|
+
# - CloudWatch detectors → detectors/cloudwatch.py
|
|
74
|
+
# - Tagging detector → detectors/tagging.py
|
|
75
|
+
# - S3 detectors → detectors/s3.py
|
|
76
|
+
# ==============================================================================
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _apply_config_filters(
|
|
80
|
+
findings: List[Dict[str, Any]], config: StackSageConfig, raw: Dict[str, Any]
|
|
81
|
+
) -> List[Dict[str, Any]]:
|
|
82
|
+
"""
|
|
83
|
+
Apply config-based exclusions and filters to findings.
|
|
84
|
+
|
|
85
|
+
Filters out findings that match exclusion rules:
|
|
86
|
+
- Resource ID exclusions
|
|
87
|
+
- Region exclusions
|
|
88
|
+
- Detector type exclusions
|
|
89
|
+
- Tag-based exclusions
|
|
90
|
+
- Reporting filters (min savings, severity)
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
findings: List of raw findings
|
|
94
|
+
config: StackSageConfig instance
|
|
95
|
+
raw: Raw scan data for looking up resource tags
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Filtered list of findings
|
|
99
|
+
"""
|
|
100
|
+
filtered = []
|
|
101
|
+
|
|
102
|
+
for finding in findings:
|
|
103
|
+
# Check detector type exclusion
|
|
104
|
+
finding_type = finding.get("type", "")
|
|
105
|
+
if config.is_detector_excluded(finding_type):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Check resource ID exclusion
|
|
109
|
+
resource_id = finding.get("id") or finding.get("resource_id")
|
|
110
|
+
if resource_id and config.is_resource_excluded(resource_id):
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# Check region exclusion
|
|
114
|
+
region = finding.get("region")
|
|
115
|
+
if region and config.is_region_excluded(region):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Check tag-based exclusion (need to look up resource tags from raw data)
|
|
119
|
+
if _is_finding_excluded_by_tags(finding, config, raw):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
# Apply reporting filters (min savings, severity)
|
|
123
|
+
if not config.should_include_finding(finding):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
filtered.append(finding)
|
|
127
|
+
|
|
128
|
+
return filtered
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_finding_excluded_by_tags(
|
|
132
|
+
finding: Dict[str, Any], config: StackSageConfig, raw: Dict[str, Any]
|
|
133
|
+
) -> bool:
|
|
134
|
+
"""Check if finding should be excluded based on resource tags."""
|
|
135
|
+
resource_id = finding.get("id") or finding.get("resource_id")
|
|
136
|
+
resource_type = finding.get("resource_type", "")
|
|
137
|
+
|
|
138
|
+
if not resource_id:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
# Map resource types to scanner data keys
|
|
142
|
+
type_mapping = {
|
|
143
|
+
"ec2": "ec2",
|
|
144
|
+
"ebs": "ebs",
|
|
145
|
+
"elb": "load_balancers",
|
|
146
|
+
"nat": "nat_gateways",
|
|
147
|
+
"eip": "eips",
|
|
148
|
+
"rds": "rds",
|
|
149
|
+
"s3": "s3_buckets",
|
|
150
|
+
"ecs": "ecs_services",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
data_key = type_mapping.get(resource_type)
|
|
154
|
+
if not data_key:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# Find resource in raw data
|
|
158
|
+
resources = raw.get(data_key, [])
|
|
159
|
+
for resource in resources:
|
|
160
|
+
# Match by ID (various ID fields depending on resource type)
|
|
161
|
+
rid = (
|
|
162
|
+
resource.get("id")
|
|
163
|
+
or resource.get("InstanceId")
|
|
164
|
+
or resource.get("VolumeId")
|
|
165
|
+
or resource.get("DBInstanceIdentifier")
|
|
166
|
+
or resource.get("NatGatewayId")
|
|
167
|
+
or resource.get("AllocationId")
|
|
168
|
+
or resource.get("Name")
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if rid == resource_id:
|
|
172
|
+
tags = resource.get("tags", []) or resource.get("Tags", [])
|
|
173
|
+
return config.is_excluded_by_tags(tags)
|
|
174
|
+
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Main analyzer integration
|
|
179
|
+
def analyze_scan(
|
|
180
|
+
raw: Dict[str, Any], session=None, options: Dict[str, Any] = None
|
|
181
|
+
) -> Dict[str, Any]:
|
|
182
|
+
"""
|
|
183
|
+
raw: dict with keys 'ec2','ebs','s3','rds' (as produced by the scanner)
|
|
184
|
+
session: boto3.Session used for CloudWatch / additional API calls
|
|
185
|
+
options: dict with optional config_path for stacksage.yml
|
|
186
|
+
"""
|
|
187
|
+
# Load configuration
|
|
188
|
+
opts = options or {}
|
|
189
|
+
config_path = opts.get("config_path")
|
|
190
|
+
config = StackSageConfig(config_path)
|
|
191
|
+
|
|
192
|
+
# When session is None (demo/offline), skip AWS API-dependent detectors
|
|
193
|
+
offline = session is None
|
|
194
|
+
use_cw = bool(opts.get("use_cloudwatch") and not offline)
|
|
195
|
+
lookback_days = int(opts.get("metrics_lookback_days", 14))
|
|
196
|
+
cw_budget = MetricBudget(int(opts.get("cw_max_queries", 500))) if use_cw else None
|
|
197
|
+
enable_tier1 = bool(opts.get("enable_tier1_posture", True) and not offline)
|
|
198
|
+
enable_cost_guardrails = bool(
|
|
199
|
+
opts.get("enable_cost_guardrails", True) and not offline
|
|
200
|
+
)
|
|
201
|
+
regions = opts.get("regions") or []
|
|
202
|
+
include_bucket_names = bool(opts.get("include_bucket_names", False))
|
|
203
|
+
|
|
204
|
+
findings = []
|
|
205
|
+
# EBS
|
|
206
|
+
findings.extend(detect_unattached_ebs(raw.get("ebs", [])))
|
|
207
|
+
# EBS gp2 → gp3 migration opportunities
|
|
208
|
+
findings.extend(detect_gp2_to_gp3_migration(raw.get("ebs", [])))
|
|
209
|
+
# EBS performance over-provisioning (CloudWatch enabled)
|
|
210
|
+
if use_cw:
|
|
211
|
+
findings.extend(
|
|
212
|
+
detect_ebs_overprovisioned_performance(
|
|
213
|
+
session, raw.get("ebs", []), days=lookback_days, budget=cw_budget
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
# EBS snapshot consolidation
|
|
217
|
+
findings.extend(detect_snapshot_consolidation(raw.get("snapshots", [])))
|
|
218
|
+
# EC2 generation upgrades (t2→t3, m4→m5, etc.) - significant savings opportunities
|
|
219
|
+
findings.extend(detect_ec2_generation_upgrades(raw.get("ec2", [])))
|
|
220
|
+
# stopped EC2
|
|
221
|
+
findings.extend(detect_stopped_ec2(raw.get("ec2", [])))
|
|
222
|
+
# idle EC2: only when CloudWatch enabled
|
|
223
|
+
if use_cw:
|
|
224
|
+
ec2_list = raw.get("ec2") or []
|
|
225
|
+
findings.extend(
|
|
226
|
+
detect_idle_ec2(session, ec2_list, days=lookback_days, budget=cw_budget)
|
|
227
|
+
)
|
|
228
|
+
# Architecture optimization: serverless migration opportunities (EC2 → Lambda + API Gateway)
|
|
229
|
+
findings.extend(
|
|
230
|
+
detect_serverless_migration_opportunities(
|
|
231
|
+
ec2_list,
|
|
232
|
+
cw_avg_fn=lambda **kwargs: cw_avg(session, budget=cw_budget, **kwargs),
|
|
233
|
+
days=lookback_days,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
# Snapshots (from scanner)
|
|
237
|
+
findings.extend(detect_old_snapshots(raw.get("snapshots", [])))
|
|
238
|
+
# NAT gateways (offline-safe: uses scanner output only)
|
|
239
|
+
findings.extend(detect_nat_gateways(raw.get("nat_gateways", [])))
|
|
240
|
+
# S3 VPC endpoint (REMOVED: speculative, low confidence, no savings data)
|
|
241
|
+
# findings.extend(detect_missing_s3_endpoint(raw.get('vpc_endpoints', []), list(set(vpc_ids))))
|
|
242
|
+
# unused EIPs using scanner output
|
|
243
|
+
findings.extend(detect_unused_eips(raw.get("eips", [])))
|
|
244
|
+
|
|
245
|
+
# Load balancer detectors
|
|
246
|
+
lb_list = raw.get("load_balancers") or []
|
|
247
|
+
# Empty target groups (offline-safe: uses scanner output only)
|
|
248
|
+
findings.extend(detect_lb_empty_target_groups(lb_list))
|
|
249
|
+
# Idle ELB (CloudWatch-based)
|
|
250
|
+
if use_cw:
|
|
251
|
+
findings.extend(
|
|
252
|
+
detect_idle_elb(session, lb_list, days=lookback_days, budget=cw_budget)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# RDS underutilization offline skip
|
|
256
|
+
if use_cw:
|
|
257
|
+
rds_list = raw.get("rds") or []
|
|
258
|
+
findings.extend(
|
|
259
|
+
detect_underutilized_rds(
|
|
260
|
+
session, rds_list, days=lookback_days, budget=cw_budget
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
# Architecture optimization: spiky RDS → Aurora Serverless v2 candidates
|
|
264
|
+
findings.extend(
|
|
265
|
+
detect_rds_to_aurora_serverless_v2(
|
|
266
|
+
session,
|
|
267
|
+
rds_list,
|
|
268
|
+
days=lookback_days,
|
|
269
|
+
budget=cw_budget,
|
|
270
|
+
cw_avg_func=cw_avg,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
# Lambda over-provisioning (CloudWatch enabled)
|
|
274
|
+
if use_cw:
|
|
275
|
+
findings.extend(
|
|
276
|
+
detect_overprovisioned_lambda(session, days=lookback_days, budget=cw_budget)
|
|
277
|
+
)
|
|
278
|
+
# Architecture optimization: Lambda x86_64 → arm64 (Graviton) recommendations
|
|
279
|
+
findings.extend(
|
|
280
|
+
detect_lambda_graviton_migration_opportunities(
|
|
281
|
+
session,
|
|
282
|
+
regions=(regions if isinstance(regions, list) else None),
|
|
283
|
+
days=lookback_days,
|
|
284
|
+
budget=cw_budget,
|
|
285
|
+
cw_avg_func=cw_avg,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# ECS/Fargate architecture optimization (ECS services inventory required)
|
|
290
|
+
ecs_services = raw.get("ecs_services") or []
|
|
291
|
+
findings.extend(
|
|
292
|
+
detect_ecs_to_fargate_migration_opportunities(
|
|
293
|
+
session,
|
|
294
|
+
ecs_services=ecs_services,
|
|
295
|
+
days=lookback_days,
|
|
296
|
+
budget=cw_budget,
|
|
297
|
+
cw_avg_func=cw_avg,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
findings.extend(
|
|
301
|
+
detect_fargate_spot_opportunities(session, ecs_services=ecs_services)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# DynamoDB unused tables (CloudWatch enabled)
|
|
305
|
+
if use_cw:
|
|
306
|
+
findings.extend(
|
|
307
|
+
detect_unused_dynamodb_tables(
|
|
308
|
+
inventory=raw,
|
|
309
|
+
cw_avg_fn=lambda **kwargs: cw_avg(session, budget=cw_budget, **kwargs),
|
|
310
|
+
pricing=None, # Use internal pricing
|
|
311
|
+
config=config,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# ElastiCache idle clusters (CloudWatch enabled)
|
|
316
|
+
if use_cw:
|
|
317
|
+
findings.extend(
|
|
318
|
+
detect_idle_elasticache_clusters(
|
|
319
|
+
inventory=raw,
|
|
320
|
+
cw_avg_fn=lambda **kwargs: cw_avg(session, budget=cw_budget, **kwargs),
|
|
321
|
+
pricing=None,
|
|
322
|
+
config=config,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# CloudFront and Route 53 (CloudWatch enabled, global services)
|
|
327
|
+
if use_cw:
|
|
328
|
+
findings.extend(
|
|
329
|
+
detect_unused_cloudfront_distributions(
|
|
330
|
+
inventory=raw,
|
|
331
|
+
cw_avg_fn=lambda **kwargs: cw_avg(session, budget=cw_budget, **kwargs),
|
|
332
|
+
pricing=None,
|
|
333
|
+
config=config,
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
findings.extend(
|
|
337
|
+
detect_unused_route53_hosted_zones(
|
|
338
|
+
inventory=raw,
|
|
339
|
+
cw_avg_fn=lambda **kwargs: cw_avg(session, budget=cw_budget, **kwargs),
|
|
340
|
+
pricing=None,
|
|
341
|
+
config=config,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# CloudWatch Logs retention
|
|
346
|
+
if use_cw:
|
|
347
|
+
findings.extend(detect_cloudwatch_logs_retention(session))
|
|
348
|
+
# S3 lifecycle suggestions (offline-safe: uses bucket metadata only)
|
|
349
|
+
findings.extend(detect_s3_lifecycle_suggestions(raw.get("s3", [])))
|
|
350
|
+
# Untagged resources (opt-in only, disabled by default to reduce noise)
|
|
351
|
+
findings.extend(detect_untagged_resources(raw, options=opts))
|
|
352
|
+
|
|
353
|
+
# Cost guardrails (Budgets + Anomaly Detection)
|
|
354
|
+
if enable_cost_guardrails:
|
|
355
|
+
findings.extend(
|
|
356
|
+
detect_budgets_guardrail(session, account_id=opts.get("account_id"))
|
|
357
|
+
)
|
|
358
|
+
findings.extend(detect_anomaly_detection_guardrail(session))
|
|
359
|
+
|
|
360
|
+
# Tier-1 posture (security + exposure + audit logging)
|
|
361
|
+
# Appended last to preserve existing finding ordering for CW/cost detectors.
|
|
362
|
+
if enable_tier1:
|
|
363
|
+
try:
|
|
364
|
+
findings.extend(
|
|
365
|
+
detect_tier1_posture(
|
|
366
|
+
session,
|
|
367
|
+
raw,
|
|
368
|
+
regions=list(regions) if isinstance(regions, list) else [],
|
|
369
|
+
include_bucket_names=include_bucket_names,
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
# Never fail the run on posture detector issues.
|
|
374
|
+
findings.append(
|
|
375
|
+
{
|
|
376
|
+
"type": "posture_detector_error",
|
|
377
|
+
"resource_type": "account",
|
|
378
|
+
"id": "account",
|
|
379
|
+
"region": None,
|
|
380
|
+
"severity": "info",
|
|
381
|
+
"confidence": 0.5,
|
|
382
|
+
"estimated_monthly_savings_usd": 0,
|
|
383
|
+
"recommended_action": "review-posture-detectors",
|
|
384
|
+
"explanation": f"Posture detectors encountered an error: {e}",
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# compute totals
|
|
389
|
+
# Aggregate totals with resource-level de-duplication to avoid double-counting
|
|
390
|
+
# Example: the same EBS volume can appear in 'unattached_ebs' and 'gp2_to_gp3_migration'
|
|
391
|
+
# We count the base monthly cost once per (resource_type,id) and sum savings separately.
|
|
392
|
+
# Apply config-based exclusions and filters
|
|
393
|
+
findings = _apply_config_filters(findings, config, raw)
|
|
394
|
+
|
|
395
|
+
total_estimated_savings = 0.0
|
|
396
|
+
cost_per_resource: Dict[str, float] = {}
|
|
397
|
+
for f in findings:
|
|
398
|
+
rid = f.get("id") or f.get("resource_id") or f.get("Name")
|
|
399
|
+
rtype = f.get("resource_type") or f.get("type")
|
|
400
|
+
key = f"{rtype}:{rid}" if rid and rtype else None
|
|
401
|
+
savings = float(f.get("estimated_monthly_savings_usd", 0) or 0)
|
|
402
|
+
cost = float(f.get("estimated_monthly_cost_usd", 0) or 0)
|
|
403
|
+
total_estimated_savings += savings
|
|
404
|
+
if key:
|
|
405
|
+
# Track the maximum cost seen for this resource to avoid double-counting
|
|
406
|
+
prev = cost_per_resource.get(key, 0.0)
|
|
407
|
+
cost_per_resource[key] = max(prev, cost)
|
|
408
|
+
total_estimated_cost = round(sum(cost_per_resource.values()), 2)
|
|
409
|
+
|
|
410
|
+
result = {
|
|
411
|
+
"findings": findings,
|
|
412
|
+
"estimated_monthly_savings": round(total_estimated_savings, 2),
|
|
413
|
+
"estimated_monthly_cost": total_estimated_cost,
|
|
414
|
+
"summary": f"Found {len(findings)} findings. Estimated monthly savings: ${round(total_estimated_savings,2)}",
|
|
415
|
+
"account_id": opts.get("account_id"), # Include account_id from options
|
|
416
|
+
"provenance": {
|
|
417
|
+
"cloudwatch_metrics": use_cw,
|
|
418
|
+
"metrics_lookback_days": lookback_days,
|
|
419
|
+
"cloudwatch_queries_attempted": cw_budget.attempted if cw_budget else None,
|
|
420
|
+
"cloudwatch_queries_used": cw_budget.used if cw_budget else None,
|
|
421
|
+
"cloudwatch_queries_remaining": cw_budget.remaining if cw_budget else None,
|
|
422
|
+
"cloudwatch_query_budget": cw_budget.remaining if cw_budget else None,
|
|
423
|
+
"cloudwatch_budget_exhausted": (
|
|
424
|
+
(cw_budget.remaining == 0) if cw_budget else None
|
|
425
|
+
),
|
|
426
|
+
"config_applied": config.config_path is not None,
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
# Evidence-grade normalization (adds reason_codes + evidence to each finding)
|
|
431
|
+
from stacksage.analyzer.evidence import normalize_findings
|
|
432
|
+
|
|
433
|
+
result["findings"] = normalize_findings(
|
|
434
|
+
result["findings"],
|
|
435
|
+
use_cloudwatch=use_cw,
|
|
436
|
+
lookback_days=lookback_days,
|
|
437
|
+
cw_provenance=result.get("provenance"),
|
|
438
|
+
)
|
|
439
|
+
logger.debug("Analysis summary: %s", result["summary"])
|
|
440
|
+
return result
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# If run as main, read reports/scan_raw.json and write findings
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
import os
|
|
446
|
+
import sys
|
|
447
|
+
|
|
448
|
+
# Enforce license before allowing direct execution
|
|
449
|
+
try:
|
|
450
|
+
from stacksage.licensing import LicenseError, enforce_license
|
|
451
|
+
|
|
452
|
+
enforce_license()
|
|
453
|
+
except LicenseError as e:
|
|
454
|
+
print(f"License error: {e}", file=sys.stderr)
|
|
455
|
+
sys.exit(2)
|
|
456
|
+
|
|
457
|
+
p = os.path.join("reports", "scan_raw.json")
|
|
458
|
+
if not os.path.exists(p):
|
|
459
|
+
print("No scan_raw.json found. Run run_scan.py first.")
|
|
460
|
+
sys.exit(1)
|
|
461
|
+
raw = json.load(open(p))
|
|
462
|
+
import boto3
|
|
463
|
+
|
|
464
|
+
session = boto3.Session()
|
|
465
|
+
out = analyze_scan(raw, session=session)
|
|
466
|
+
with open("reports/findings.json", "w") as f:
|
|
467
|
+
json.dump(out, f, indent=2)
|
|
468
|
+
print("Wrote reports/findings.json")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StackSage Detectors Module
|
|
3
|
+
|
|
4
|
+
All cost optimization and waste detection logic organized by resource type.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# CloudWatch Detectors
|
|
8
|
+
from .cloudwatch import detect_cloudwatch_logs_retention, detect_overprovisioned_lambda
|
|
9
|
+
|
|
10
|
+
# EBS Detectors
|
|
11
|
+
from .ebs import (
|
|
12
|
+
detect_ebs_overprovisioned_performance,
|
|
13
|
+
detect_gp2_to_gp3_migration,
|
|
14
|
+
detect_old_snapshots,
|
|
15
|
+
detect_snapshot_consolidation,
|
|
16
|
+
detect_unattached_ebs,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# EC2 Detectors
|
|
20
|
+
from .ec2 import detect_ec2_generation_upgrades, detect_idle_ec2, detect_stopped_ec2
|
|
21
|
+
|
|
22
|
+
# Cost guardrails
|
|
23
|
+
from .guardrails import detect_anomaly_detection_guardrail, detect_budgets_guardrail
|
|
24
|
+
|
|
25
|
+
# Network Detectors
|
|
26
|
+
from .network import (
|
|
27
|
+
detect_idle_elb,
|
|
28
|
+
detect_lb_empty_target_groups,
|
|
29
|
+
detect_missing_s3_endpoint,
|
|
30
|
+
detect_nat_gateways,
|
|
31
|
+
detect_unused_eips,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# RDS Detectors
|
|
35
|
+
from .rds import detect_underutilized_rds
|
|
36
|
+
|
|
37
|
+
# S3 Detectors
|
|
38
|
+
from .s3 import detect_s3_lifecycle_suggestions
|
|
39
|
+
|
|
40
|
+
# Tagging Detector
|
|
41
|
+
from .tagging import detect_untagged_resources
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# EBS
|
|
45
|
+
"detect_unattached_ebs",
|
|
46
|
+
"detect_ebs_overprovisioned_performance",
|
|
47
|
+
"detect_gp2_to_gp3_migration",
|
|
48
|
+
"detect_old_snapshots",
|
|
49
|
+
"detect_snapshot_consolidation",
|
|
50
|
+
# EC2
|
|
51
|
+
"detect_stopped_ec2",
|
|
52
|
+
"detect_idle_ec2",
|
|
53
|
+
"detect_ec2_generation_upgrades",
|
|
54
|
+
# RDS
|
|
55
|
+
"detect_underutilized_rds",
|
|
56
|
+
# Network
|
|
57
|
+
"detect_unused_eips",
|
|
58
|
+
"detect_idle_elb",
|
|
59
|
+
"detect_lb_empty_target_groups",
|
|
60
|
+
"detect_nat_gateways",
|
|
61
|
+
"detect_missing_s3_endpoint",
|
|
62
|
+
# CloudWatch
|
|
63
|
+
"detect_overprovisioned_lambda",
|
|
64
|
+
"detect_cloudwatch_logs_retention",
|
|
65
|
+
# Cost guardrails
|
|
66
|
+
"detect_budgets_guardrail",
|
|
67
|
+
"detect_anomaly_detection_guardrail",
|
|
68
|
+
# Tagging
|
|
69
|
+
"detect_untagged_resources",
|
|
70
|
+
# S3
|
|
71
|
+
"detect_s3_lifecycle_suggestions",
|
|
72
|
+
]
|