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.
@@ -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
+ ]