From 863e6f9d70d58b1b805e0b0c846497a4365d9ad8 Mon Sep 17 00:00:00 2001 From: Jakub Kondrat Date: Mon, 6 Jul 2026 16:07:00 +0200 Subject: [PATCH 1/5] fix: improve EBS cost estimates and savings display accuracy --- reporter/cost_estimator.py | 164 +++++++++++++++++++++++++++++++------ static/app.js | 65 ++++++++++----- 2 files changed, 186 insertions(+), 43 deletions(-) diff --git a/reporter/cost_estimator.py b/reporter/cost_estimator.py index 8a65cca..6927247 100644 --- a/reporter/cost_estimator.py +++ b/reporter/cost_estimator.py @@ -356,21 +356,35 @@ def _savings_cost001(block_content: str, pricing: dict, usage: dict) -> SavingsR def _savings_cost004(block_content: str, pricing: dict, usage: dict) -> SavingsResult: """COST-004 io1/io2 → gp3.""" - ebs = pricing.get("ebs_per_gb_month", {}) + ebs = pricing.get("ebs_per_gb_month", {}) iops_pricing = pricing.get("ebs_iops_per_iops_month", {}) size_m = re.search(r'(?:volume_size|size)\s*=\s*(\d+)', block_content) iops_m = re.search(r'\biops\s*=\s*(\d+)', block_content) vol_m = re.search(r'(?:volume_type|type)\s*=\s*["\']([^"\']+)["\']', block_content) - size = int(size_m.group(1)) if size_m else 100 + size: Optional[int] = int(size_m.group(1)) if size_m else None iops = int(iops_m.group(1)) if iops_m else 3000 vol_type = vol_m.group(1) if vol_m else "io1" - before = size * 0.125 + iops * iops_pricing.get("io1", 0.065) - after = size * ebs.get("gp3", 0.08) - saving = max(0.0, before - after) + + if size is None: + # Try variable resolution; default to 50 + filepath = usage.get("_filepath", "") + var_ref_m = re.search(r'(?:volume_size|size)\s*=\s*((?:var|local)\.\S+)', block_content) + if var_ref_m and filepath: + resolved = _resolve_number_from_files(var_ref_m.group(1), filepath) + if resolved is not None: + size = resolved + if size is None: + size = 50 + + per_gb = ebs.get(vol_type, ebs.get("io1", 0.125)) + per_iops = iops_pricing.get(vol_type, iops_pricing.get("io1", 0.065)) + before = round(size * per_gb + iops * per_iops, 2) + after = round(size * ebs.get("gp3", 0.08), 2) + saving = round(max(0.0, before - after), 2) conf = "high" if (size_m and iops_m) else "medium" return SavingsResult( saving, saving, before, after, - [f"{vol_type} {size}GB {iops}IOPS → gp3"], + [f"{vol_type} {size}GB {iops}IOPS → gp3 (${before:.2f} → ${after:.2f})"], conf, ) @@ -426,6 +440,72 @@ def _savings_cost007(block_content: str, pricing: dict, usage: dict) -> SavingsR ) +def _resolve_number_from_files(var_expr: str, filepath: str) -> Optional[int]: + """ + Resolve a Terraform variable reference (e.g. ``var.grq["root_dev_size"]``, + ``var.root_dev_size``, ``local.size``) to a concrete integer by scanning all + sibling ``.tf`` files in the same directory for matching assignments. + + Returns the most-common numeric value found, or ``None`` if unresolvable. + """ + if not filepath: + return None + + # Extract the attribute key from expressions such as: + # var.grq["root_dev_size"] → root_dev_size + # var.metrics["root_dev_size"] → root_dev_size + # var.root_dev_size → root_dev_size + # local.volume_size → volume_size + key_m = re.search(r'\["([^"]+)"\]', var_expr) + if not key_m: + key_m = re.search(r'(?:var|local)\.(\w+)\.(\w+)', var_expr) + if key_m: + key_m = type('_M', (), {'group': lambda self, n: key_m.group(n)})() + # Use the last segment as the key + key_m = re.search(r'\.(\w+)$', var_expr) + if not key_m: + key_m = re.search(r'(?:var|local)\.(\w+)', var_expr) + + if not key_m: + return None + + key = key_m.group(1) + + # Read all sibling .tf files in the same directory + try: + dir_path = os.path.dirname(filepath) + combined = "" + for fname in sorted(os.listdir(dir_path)): + if fname.endswith(".tf"): + try: + with open( + os.path.join(dir_path, fname), "r", encoding="utf-8", errors="replace" + ) as fh: + combined += fh.read() + "\n" + except Exception: + pass + except Exception: + return None + + if not combined: + return None + + # Find all assignments: key = (quoted or unquoted key) + values = [ + int(m) + for m in re.findall( + rf'["\']?{re.escape(key)}["\']?\s*=\s*(\d+)', combined + ) + ] + + if not values: + return None + + # Return the most common value as the representative estimate + from collections import Counter + return Counter(values).most_common(1)[0][0] + + def _savings_cost008(block_content: str, pricing: dict, usage: dict) -> SavingsResult: """COST-008 EC2 detailed monitoring — fixed $2.10/instance/month.""" before = 2.10 @@ -438,16 +518,34 @@ def _savings_cost008(block_content: str, pricing: dict, usage: dict) -> SavingsR def _savings_cost009(block_content: str, pricing: dict, usage: dict) -> SavingsResult: """COST-009 gp2 → gp3 EBS volume.""" - ebs = pricing.get("ebs_per_gb_month", {}) + ebs = pricing.get("ebs_per_gb_month", {}) size_m = re.search(r'volume_size\s*=\s*(\d+)', block_content) - size = int(size_m.group(1)) if size_m else 20 + size: Optional[int] = int(size_m.group(1)) if size_m else None + conf = "high" + assumptions: list = [] + + if size is None: + # Try to resolve a variable reference such as var.grq["root_dev_size"] + filepath = usage.get("_filepath", "") + var_ref_m = re.search(r'volume_size\s*=\s*((?:var|local)\.\S+)', block_content) + if var_ref_m and filepath: + resolved = _resolve_number_from_files(var_ref_m.group(1), filepath) + if resolved is not None: + size = resolved + conf = "medium" + assumptions.append( + f"volume_size resolved from variable ({var_ref_m.group(1)} = {size}GB)" + ) + + if size is None: + size = 50 + conf = "medium" + assumptions.append("volume_size not found, assumed 50 GB") + before = size * ebs.get("gp2", 0.10) after = size * ebs.get("gp3", 0.08) saving = before - after - conf = "high" if size_m else "medium" - assumptions = [f"{size}GB gp2→gp3 ($0.02/GB/mo saving)"] - if not size_m: - assumptions.append("volume_size not found, assumed 20 GB") + assumptions.insert(0, f"{size}GB gp2→gp3 ($0.02/GB/mo saving)") return SavingsResult(saving, saving, before, after, assumptions, conf) @@ -793,8 +891,15 @@ def estimate_savings( # Track which block this finding belongs to for per-block cap (see below). block_key = (block["file"], block["start_line"]) if block else None + # Pass the block's filepath so savings functions can resolve variable references + # (e.g. volume_size = var.grq["root_dev_size"]) via sibling .tf files. + usage_for_finding = { + **usage_with_hints, + "_filepath": block["file"] if block else fpath, + } + try: - result: SavingsResult = savings_fn(block_content, pricing, usage_with_hints) + result: SavingsResult = savings_fn(block_content, pricing, usage_for_finding) except Exception as exc: logger.warning("savings_fn for %s failed: %s", rule_id, exc) continue @@ -1044,22 +1149,35 @@ def _cost_aws_db_instance(block: dict, pricing: dict, usage: dict) -> ResourceCo def _cost_aws_ebs_volume(block: dict, pricing: dict, usage: dict) -> ResourceCost: ebs = pricing.get("ebs_per_gb_month", {}) iops_pricing = pricing.get("ebs_iops_per_iops_month", {}) - size_m = re.search(r'(?:size|volume_size)\s*=\s*(\d+)', block["content"]) - vol_m = re.search(r'(?:type|volume_type)\s*=\s*["\']([^"\']+)["\']', block["content"]) - size = int(size_m.group(1)) if size_m else 20 + content = block["content"] + size_m = re.search(r'(?:size|volume_size)\s*=\s*(\d+)', content) + vol_m = re.search(r'(?:type|volume_type)\s*=\s*["\']([^"\']+)["\']', content) + size: Optional[int] = int(size_m.group(1)) if size_m else None vol_type = vol_m.group(1) if vol_m else "gp3" - cost = size * ebs.get(vol_type, 0.08) + + if size is None: + # Try to resolve variable reference such as var.foo["volume_size"] + size_var_m = re.search(r'(?:size|volume_size)\s*=\s*((?:var|local)\.\S+)', content) + if size_var_m: + resolved = _resolve_number_from_files(size_var_m.group(1), block.get("file", "")) + if resolved is not None: + size = resolved + + if size is None: + size = 20 + + cost = size * ebs.get(vol_type, 0.08) iops_cost = 0.0 - iops = 0 + iops = 0 if vol_type in ("io1", "io2"): - iops_m = re.search(r'\biops\s*=\s*(\d+)', block["content"]) - iops = int(iops_m.group(1)) if iops_m else 3000 # default: provisioned IOPS minimum practical value - iops_cost = iops * iops_pricing.get("io1", 0.065) - conf = "high" if (size_m and vol_m) else "medium" + iops_m = re.search(r'\biops\s*=\s*(\d+)', content) + iops = int(iops_m.group(1)) if iops_m else 3000 + iops_cost = iops * iops_pricing.get(vol_type, iops_pricing.get("io1", 0.065)) + conf = "high" if (size_m and vol_m) else ("medium" if (size_m or vol_m) else "low") assumptions = [f"{vol_type} {size}GB"] if iops: assumptions.append(f"{iops} IOPS") - return _rc(block, cost + iops_cost, 0.0, assumptions, conf) + return _rc(block, round(cost + iops_cost, 2), 0.0, assumptions, conf) def _cost_aws_nat_gateway(block: dict, pricing: dict, usage: dict) -> ResourceCost: diff --git a/static/app.js b/static/app.js index 56256db..f83cba7 100644 --- a/static/app.js +++ b/static/app.js @@ -1100,34 +1100,59 @@ function initApp() { ` : ''} ${first.scanner === 'regex' ? (() => { - // Look for computed cost data from the cost estimator (Phase 1+2) + // Aggregate savings across ALL occurrences of this rule const perFinding = currentGradeReport?.metrics?.savings_estimate?.per_finding || []; - const costData = perFinding.find( - pf => pf.rule_id === first.rule_id && pf.file === first.file && pf.line === first.line - ); - if (costData && (costData.saving_high > 0 || costData.before_usd > 0)) { - const savingStr = costData.saving_low === costData.saving_high - ? `$${costData.saving_low.toFixed(2)}` - : `$${costData.saving_low.toFixed(2)} – $${costData.saving_high.toFixed(2)}`; - const confIcon = costData.confidence === 'high' ? '🟢' : costData.confidence === 'medium' ? '🟡' : '⚪'; - const assumptions = (costData.assumptions || []).join('; '); + const allMatches = perFinding.filter(pf => pf.rule_id === first.rule_id); + const totalLow = allMatches.reduce((s, pf) => s + (pf.saving_low || 0), 0); + const totalHigh = allMatches.reduce((s, pf) => s + (pf.saving_high || 0), 0); + const totalBefore = allMatches.reduce((s, pf) => s + (pf.before_usd || 0), 0); + // For confidence: use lowest across all matches + const confRank = { high: 2, medium: 1, low: 0 }; + const minConf = allMatches.reduce((c, pf) => { + const r = confRank[pf.confidence] ?? 0; + return r < confRank[c] ? pf.confidence : c; + }, 'high'); + const confIcon = minConf === 'high' ? '🟢' : minConf === 'medium' ? '🟡' : '⚪'; + if (totalHigh > 0) { + const fmt2 = n => `$${n.toFixed(2)}`; + const savingStr = Math.abs(totalLow - totalHigh) < 0.01 + ? fmt2(totalLow) + : `${fmt2(totalLow)} – ${fmt2(totalHigh)}`; + const countNote = allMatches.length > 1 ? ` across ${allMatches.length} occurrences` : ''; + const assumptions = allMatches[0]?.assumptions?.join('; ') || ''; let afterStr = ''; - if (costData.before_usd > 0) { - const afterBest = Math.max(0, costData.before_usd - costData.saving_high); - const afterWorst = Math.max(0, costData.before_usd - costData.saving_low); - afterStr = afterBest === afterWorst - ? `$${afterBest.toFixed(2)}` - : `$${afterBest.toFixed(2)}–$${afterWorst.toFixed(2)}`; + if (totalBefore > 0) { + const afterBest = Math.max(0, totalBefore - totalHigh); + const afterWorst = Math.max(0, totalBefore - totalLow); + afterStr = Math.abs(afterBest - afterWorst) < 0.01 + ? fmt2(afterBest) + : `${fmt2(afterBest)}–${fmt2(afterWorst)}`; } return `
Estimated Saving: - ${savingStr}/mo ${confIcon} - ${afterStr ? `current: $${costData.before_usd.toFixed(2)} → after: ${afterStr}` : ''} + ${savingStr}/mo ${confIcon}${countNote} + ${afterStr ? `current: ${fmt2(totalBefore)} → after: ${afterStr}` : ''}
`; } - // Fall back to the static estimated_savings text + // Fall back to the static estimated_savings text — try to multiply by occurrence count + const rawSavings = first.estimated_savings || ''; + let aggregatedSavings = rawSavings; + if (fileCount > 1 && rawSavings) { + // Match "$10-50" or "$10–50" or "$10-50+" + const rangeMatch = rawSavings.match(/\$(\d+(?:\.\d+)?)\s*[-–]\s*\$?(\d+(?:\.\d+)?)\+?/); + // Match single "$10" or "$10+" + const singleMatch = !rangeMatch && rawSavings.match(/\$(\d+(?:\.\d+)?)\+?/); + if (rangeMatch) { + const lo = (parseFloat(rangeMatch[1]) * fileCount).toFixed(0); + const hi = (parseFloat(rangeMatch[2]) * fileCount).toFixed(0); + aggregatedSavings = `$${lo}–$${hi}/mo across ${fileCount} occurrences`; + } else if (singleMatch) { + const v = (parseFloat(singleMatch[1]) * fileCount).toFixed(0); + aggregatedSavings = `$${v}+/mo across ${fileCount} occurrences`; + } + } return `
- Potential Savings: ${escapeHtml(first.estimated_savings)} + Potential Savings: ${escapeHtml(aggregatedSavings)}
`; })() : ''}
From 3bc5d4412cf7e0a81ee7ab5bf63d11a4df49ceb3 Mon Sep 17 00:00:00 2001 From: Jakub Kondrat Date: Mon, 6 Jul 2026 16:07:00 +0200 Subject: [PATCH 2/5] fix: reclassify COST rule severity --- rules/definitions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rules/definitions.py b/rules/definitions.py index f809436..b8bf914 100644 --- a/rules/definitions.py +++ b/rules/definitions.py @@ -213,7 +213,7 @@ def check(self, content, filepath=None): RegexRule( id="COST-002", name="Expensive Instance Type", - severity="High", + severity="Medium", description="Usage of very large instance types (xlarge+). Ensure this capacity is actually needed.", remediation="Review utilization metrics. Consider rightsizing or Spot Instances.", estimated_savings="$100-500+/month per instance", @@ -231,7 +231,7 @@ def check(self, content, filepath=None): RegexRule( id="COST-004", name="EBS Provisioned IOPS (io1/io2)", - severity="High", + severity="Medium", description="EBS volume using Provisioned IOPS (io1/io2) type. These are very expensive — io2 costs 56× more than gp3 per GB plus per-IOPS charges.", remediation="Verify if gp3 can meet performance requirements at a lower cost.", estimated_savings="$50-200+/month per volume", @@ -240,7 +240,7 @@ def check(self, content, filepath=None): RegexRule( id="COST-005", name="Expensive NAT Gateway", - severity="High", + severity="Medium", description="NAT Gateways are expensive managed services. Ensure they are strictly necessary.", remediation="Consider using VPC Endpoints, NAT Instances for non-critical workloads, or share a single NAT Gateway across multiple subnets.", estimated_savings="$30-40/month + data processing fees per gateway", From 688fed4455045e9d26346033c1852a52dd4dd2a2 Mon Sep 17 00:00:00 2001 From: Jakub Kondrat Date: Mon, 6 Jul 2026 16:20:37 +0200 Subject: [PATCH 3/5] feat: collapse occurrences list by default --- static/app.js | 8 +++++--- static/style.css | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/static/app.js b/static/app.js index f83cba7..4dd90d7 100644 --- a/static/app.js +++ b/static/app.js @@ -1155,9 +1155,10 @@ function initApp() { Potential Savings: ${escapeHtml(aggregatedSavings)}
`; })() : ''} -
- Occurrences: ${fileCount} ${fileCount === 1 ? 'location' : 'locations'} -
+
+ + Occurrences: ${fileCount} ${fileCount === 1 ? 'location' : 'locations'} +
${findings.map(f => { // Different display for different scanners @@ -1180,6 +1181,7 @@ function initApp() {
`}).join('')} +
Fix: ${linkifyUrls(first.remediation, 500)}
diff --git a/static/style.css b/static/style.css index 4b8812c..6e3319a 100644 --- a/static/style.css +++ b/static/style.css @@ -1543,6 +1543,36 @@ select option:disabled { } /* Occurrences list styling */ +.occurrences-expand { + margin: 0.5rem 0; +} + +.occurrences-summary { + cursor: pointer; + user-select: none; + list-style: none; + display: block; +} + +.occurrences-summary::-webkit-details-marker { + display: none; +} + +.occurrences-summary::before { + content: '\25B6 '; + font-size: 0.7em; + vertical-align: middle; + color: var(--text-secondary); +} + +.occurrences-expand[open] .occurrences-summary::before { + content: '\25BC '; +} + +.occurrences-summary:hover { + color: var(--text-main); +} + .occurrences-list { margin: 1rem 0; padding: 0.5rem; From 4cabe968e9936c224358687ab1dba350353b9e6b Mon Sep 17 00:00:00 2001 From: Jakub Kondrat Date: Mon, 6 Jul 2026 16:56:30 +0200 Subject: [PATCH 4/5] feat: implement COST-010 S3 lifecycle savings estimate --- reporter/cost_estimator.py | 30 +++++++++++++++++++++++++++++- reporter/pricing_table.json | 2 ++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/reporter/cost_estimator.py b/reporter/cost_estimator.py index 6927247..458bea4 100644 --- a/reporter/cost_estimator.py +++ b/reporter/cost_estimator.py @@ -549,6 +549,34 @@ def _savings_cost009(block_content: str, pricing: dict, usage: dict) -> SavingsR return SavingsResult(saving, saving, before, after, assumptions, conf) +def _savings_cost010(block_content: str, pricing: dict, usage: dict) -> SavingsResult: + """COST-010 Missing S3 Lifecycle Policy — estimate from storage tiering. + + Assumes a default bucket size of 2 TB. Two tiering scenarios: + low: 40 % of data transitions to Standard-IA + high: 70 % of data transitions to Glacier + Confidence is "low" because actual stored GB is unknown from Terraform. + """ + per_gb_std = pricing.get("s3_per_gb_standard", 0.023) + per_gb_ia = pricing.get("s3_per_gb_ia", 0.0125) + per_gb_glc = pricing.get("s3_per_gb_glacier", 0.004) + gb = usage.get("s3_lifecycle_gb", 200.0) # 200 GB default + + before = round(gb * per_gb_std, 2) + saving_low = round(gb * 0.40 * (per_gb_std - per_gb_ia), 2) + saving_high = round(gb * 0.70 * (per_gb_std - per_gb_glc), 2) + after_high = round(max(0.0, before - saving_high), 2) + return SavingsResult( + saving_low, saving_high, + before, after_high, + [ + f"Assumed {int(gb)}GB stored; low=40% to Standard-IA, high=70% to Glacier", + "Actual savings depend on real data volume and access patterns", + ], + "low", + ) + + def _savings_cost014(block_content: str, pricing: dict, usage: dict) -> SavingsResult: """COST-014 Route53 health check.""" before = pricing.get("route53_health_check_per_month", 0.50) @@ -767,7 +795,7 @@ def _fn(block_content: str, pricing: dict, usage: dict) -> SavingsResult: "COST-007": _savings_cost007, "COST-008": _savings_cost008, "COST-009": _savings_cost009, - "COST-010": _savings_zero("S3 lifecycle saving depends on object churn; no static estimate"), + "COST-010": _savings_cost010, "COST-011": _savings_zero("AWS Budget is a governance control; no direct cost delta"), "COST-014": _savings_cost014, "COST-015": _savings_cost015, diff --git a/reporter/pricing_table.json b/reporter/pricing_table.json index fa6bb48..aa57dff 100644 --- a/reporter/pricing_table.json +++ b/reporter/pricing_table.json @@ -123,6 +123,8 @@ "cloudwatch_logs_per_gb_ingested": 0.5, "cloudwatch_logs_per_gb_stored": 0.03, "s3_per_gb_standard": 0.023, + "s3_per_gb_ia": 0.0125, + "s3_per_gb_glacier": 0.004, "dynamodb_per_rcu_hour": 0.00013, "dynamodb_per_wcu_hour": 0.00065, "sqs_per_1m_requests": 0.4, From 7b224c00143b38c6e0dcd9d4452db313e27f9da0 Mon Sep 17 00:00:00 2001 From: Jakub Kondrat Date: Mon, 6 Jul 2026 17:13:52 +0200 Subject: [PATCH 5/5] distribute spot savings proportionally by instance cost --- reporter/cost_estimator.py | 52 +++++++++++++++++++++++++------------- static/app.js | 9 +++++-- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/reporter/cost_estimator.py b/reporter/cost_estimator.py index 458bea4..9faff05 100644 --- a/reporter/cost_estimator.py +++ b/reporter/cost_estimator.py @@ -552,7 +552,7 @@ def _savings_cost009(block_content: str, pricing: dict, usage: dict) -> SavingsR def _savings_cost010(block_content: str, pricing: dict, usage: dict) -> SavingsResult: """COST-010 Missing S3 Lifecycle Policy — estimate from storage tiering. - Assumes a default bucket size of 2 TB. Two tiering scenarios: + Assumes a default bucket size of 200 GB. Two tiering scenarios: low: 40 % of data transitions to Standard-IA high: 70 % of data transitions to Glacier Confidence is "low" because actual stored GB is unknown from Terraform. @@ -962,38 +962,54 @@ def estimate_savings( if cost012_idx is not None: fleet_pf = per_finding[cost012_idx] cost012_all = [f for f in findings if f.get("rule_id") == "COST-012"] - # Resolve each finding to its resource block; drop those without a match - # (data sources, unmapped types) to prevent orphaned headline inflation. + # Resolve each finding to its resource block; drop those without a match. cost012_resolved = [ (f, b) for f in cost012_all for b in [_find_block(blocks, f.get("file", ""), f.get("line", 0))] if b is not None ] + # If fewer findings resolved than there are aws_instance blocks, fall back + # to distributing across ALL instance blocks. This handles the common case + # where COST-012 fires once per file (the first aws_instance) but the fleet + # actually contains several instances — without this the entire fleet saving + # is pinned to one cheap block and the JS cap silently swallows it. + all_instance_blocks = blocks.get("aws_instance", []) + if len(cost012_resolved) < len(all_instance_blocks) and all_instance_blocks: + cost012_resolved = [ + (cost012_all[0] if cost012_all else {}, b) + for b in all_instance_blocks + ] + n = len(cost012_resolved) if n == 0: # No resolvable blocks — remove the entry entirely per_finding.pop(cost012_idx) - elif n == 1: - # Single instance — rewrite in-place with resolved block coords - f0, b0 = cost012_resolved[0] - per_finding[cost012_idx].update({ - "file": f0.get("file", ""), - "line": f0.get("line", 0), - "block_file": b0["file"], - "block_line": b0["start_line"], - }) else: + # Proportional distribution: each instance saves spot_pct × its own + # EC2 compute cost. Using EC2-only cost (same basis as fleet_before) + # prevents EBS storage from inflating the per-instance saving. + fleet_before = fleet_pf["before_usd"] or 1.0 + spot_pct_low = fleet_pf["saving_low"] / fleet_before + spot_pct_high = fleet_pf["saving_high"] / fleet_before + ec2_prices_local = pricing.get("ec2_instances", {}) + distributed = [] for f, b in cost012_resolved: + m2 = re.search(r'instance_type\s*=\s*["\'](\S+)["\']', b.get("content", "")) + inst_cost = ec2_prices_local.get(m2.group(1).strip(), ec2_fallback) if m2 else ec2_fallback + # For blocks that were synthesised (no direct finding), fall back + # to the block's own coordinates so the UI can still render them. + f_file = f.get("file", b["file"]) if isinstance(f, dict) and f else b["file"] + f_line = f.get("line", b["start_line"]) if isinstance(f, dict) and f else b["start_line"] distributed.append({ "rule_id": "COST-012", - "file": f.get("file", ""), - "line": f.get("line", 0), - "before_usd": round(fleet_pf["before_usd"] / n, 2), - "after_usd": round(fleet_pf["after_usd"] / n, 2), - "saving_low": round(fleet_pf["saving_low"] / n, 2), - "saving_high": round(fleet_pf["saving_high"] / n, 2), + "file": f_file, + "line": f_line, + "before_usd": round(inst_cost, 2), + "after_usd": round(inst_cost * (1 - spot_pct_high), 2), + "saving_low": round(inst_cost * spot_pct_low, 2), + "saving_high": round(inst_cost * spot_pct_high, 2), "assumptions": fleet_pf["assumptions"], "confidence": fleet_pf["confidence"], "block_file": b["file"], diff --git a/static/app.js b/static/app.js index 4dd90d7..e0164d6 100644 --- a/static/app.js +++ b/static/app.js @@ -1626,8 +1626,13 @@ function initApp() { .filter(r => r.total_usd_month > 0) .map(r => { const matched = findingsByBlock[`${r.file}::${r.line}`] || []; - const sLow = Math.min(matched.reduce((s, pf) => s + pf.saving_low, 0), r.total_usd_month); - const sHigh = Math.min(matched.reduce((s, pf) => s + pf.saving_high, 0), r.total_usd_month); + // Spot savings (COST-012) apply to EC2 compute only, not attached + // storage — cap at 90 % of resource cost so we don't imply the + // instance is free even when EC2+EBS combined is $9 vs $8 saving. + const hasSpot = matched.some(pf => pf.rule_id === 'COST-012'); + const cap = hasSpot ? r.total_usd_month * 0.90 : r.total_usd_month; + const sLow = Math.min(matched.reduce((s, pf) => s + pf.saving_low, 0), cap); + const sHigh = Math.min(matched.reduce((s, pf) => s + pf.saving_high, 0), cap); return { ...r, savings_low: sLow, savings_high: sHigh }; }) .sort((a, b) => b.savings_high - a.savings_high || b.total_usd_month - a.total_usd_month);