diff --git a/data/seed/aws_pricelist_seed.json b/data/seed/aws_pricelist_seed.json index 315ce57..aafd6ac 100644 --- a/data/seed/aws_pricelist_seed.json +++ b/data/seed/aws_pricelist_seed.json @@ -82,6 +82,9 @@ {"vendor": "aws", "service": "AWSKMS", "region": "us-east-1", "usage_metric": "KMS-Key-Month", "unit": "Keys", "price_usd": 1.00, "source": "seed", "description": "KMS customer-managed key monthly pricing ($1.00 per key-month)"}, {"vendor": "aws", "service": "AWSKMS", "region": "us-east-1", "usage_metric": "KMS-API-Request", "unit": "requests", "price_usd": 0, "start_usage_amount": 0, "end_usage_amount": 20000, "source": "seed", "description": "KMS API request free tier: first 20,000 requests/month at $0"}, {"vendor": "aws", "service": "AWSKMS", "region": "us-east-1", "usage_metric": "KMS-API-Request", "unit": "requests", "price_usd": 0.000003, "start_usage_amount": 20000, "source": "seed", "description": "KMS symmetric API request pricing ($0.03 per 10K requests beyond free tier)"}, + {"vendor": "aws", "service": "AWSWAF", "region": "us-east-1", "usage_metric": "WAF-WebACL-Month", "unit": "Months", "price_usd": 5.00, "source": "seed", "description": "WAFv2 web ACL monthly pricing ($5.00 per web-ACL-month)"}, + {"vendor": "aws", "service": "AWSWAF", "region": "us-east-1", "usage_metric": "WAF-Rule-Month", "unit": "Rules", "price_usd": 1.00, "source": "seed", "description": "WAFv2 rule monthly pricing ($1.00 per rule-month)"}, + {"vendor": "aws", "service": "AWSWAF", "region": "us-east-1", "usage_metric": "WAF-Request", "unit": "requests", "price_usd": 0.0000006, "source": "seed", "description": "WAFv2 request-inspection pricing ($0.60 per million requests)"}, {"vendor": "aws", "service": "AmazonCloudWatch", "region": "us-east-1", "usage_metric": "CloudWatch-Metric-Month", "unit": "Metrics", "price_usd": 0.30, "source": "seed", "description": "CloudWatch custom metric pricing ($0.30 per metric-month, standard resolution)"}, {"vendor": "aws", "service": "AmazonCloudWatch", "region": "us-east-1", "usage_metric": "CloudWatch-Alarm-Month", "unit": "Alarms", "price_usd": 0.10, "source": "seed", "description": "CloudWatch alarm pricing ($0.10 per standard-resolution alarm-month)"}, {"vendor": "aws", "service": "AmazonCloudWatch", "region": "us-east-1", "usage_metric": "CloudWatch-GetMetricData", "unit": "Metrics", "price_usd": 0, "start_usage_amount": 0, "end_usage_amount": 1000000, "source": "seed", "description": "CloudWatch GetMetricData free tier: first 1M metrics requested per month at $0"}, diff --git a/infra_cost_model/resources/registry.py b/infra_cost_model/resources/registry.py index 2e377a0..da73038 100644 --- a/infra_cost_model/resources/registry.py +++ b/infra_cost_model/resources/registry.py @@ -28,6 +28,7 @@ from .azure import AzureFunction, CosmosDB, APIManagement, AzureOpenAI, AzureBlobStorage from .misc_services import SecretsManagerSecret, ECRRepository, Route53Zone from .kms import KMSKey +from .waf import WAFv2WebACL from .data_transfer import DataTransferNode @@ -238,6 +239,9 @@ def extract(cls, resource_address: str, resource_data: dict, # AWS KMS ResourceRegistry.register(KMSKey) +# AWS WAFv2 +ResourceRegistry.register(WAFv2WebACL) + # AWS Data Transfer (usage-derived node, no IaC resource) ResourceRegistry.register(DataTransferNode) diff --git a/infra_cost_model/resources/waf.py b/infra_cost_model/resources/waf.py new file mode 100644 index 0000000..8519780 --- /dev/null +++ b/infra_cost_model/resources/waf.py @@ -0,0 +1,103 @@ +"""AWS WAFv2 web ACL resource model. + +Native handler for AWS WAFv2 web ACLs (``aws_wafv2_web_acl``). +- Recurring cost: $/web-ACL-month + $/rule-month (per rule in the ACL) +- Usage cost: $/request inspected + +The optional add-on SKUs (Bot Control, Fraud Control / Account Takeover +Prevention, CAPTCHA, intelligent threat mitigation) are separate products and +out of scope here — the same way the ALB handler defers NLB and the KMS handler +defers asymmetric-key requests. Classic WAF (``aws_waf_web_acl``) is a distinct, +retired product and is intentionally not matched. +""" + +from typing import Optional +from infra_cost_model.pricing.catalog import PricingCatalog +from .types import RoutingResource, ResourceExtract + + +class WAFv2WebACL(RoutingResource): + """AWS WAFv2 web ACL - routing node with per-ACL + per-rule + per-request pricing.""" + + @property + def valid_metrics(self) -> list[str]: + return ["webAcls", "rules", "requests"] + + @property + def catalog_metrics(self) -> dict[str, str]: + return { + "webAcls": "WAF-WebACL-Month", + "rules": "WAF-Rule-Month", + "requests": "WAF-Request", + } + + @classmethod + def from_address(cls, resource_address: str) -> Optional["WAFv2WebACL"]: + if (resource_address.startswith("aws_wafv2_web_acl.") or + resource_address.startswith("aws.wafv2.WebAcl:") or + resource_address.startswith("aws:wafv2:WebAcl:") or + "WAFv2::WebACL:" in resource_address): + return cls() + return None + + @classmethod + def extract_tf(cls, resource: dict) -> ResourceExtract: + values = resource.get("values", {}) + return ResourceExtract( + resource_address=resource.get("address", ""), + node_type="routing", provider="aws", service="AWSWAF", + region=values.get("region"), + config={ + "name": values.get("name"), + "scope": values.get("scope", "REGIONAL"), + "ruleCount": len(values.get("rule") or []), + }, + ) + + @classmethod + def extract_pulumi(cls, resource: dict) -> ResourceExtract: + inputs = resource.get("inputs", {}) + return ResourceExtract( + resource_address=resource.get("id", ""), + node_type="routing", provider="aws", service="AWSWAF", + region=inputs.get("region"), + config={ + "name": inputs.get("name"), + "scope": inputs.get("scope", "REGIONAL"), + "ruleCount": len(inputs.get("rules") or []), + }, + ) + + @classmethod + def extract_cdk(cls, resource: dict) -> ResourceExtract: + properties = resource.get("Properties", {}) + return ResourceExtract( + resource_address=resource.get("LogicalId", ""), + node_type="routing", provider="aws", service="AWSWAF", + region=None, + config={ + "name": properties.get("Name"), + "scope": properties.get("Scope", "REGIONAL"), + "ruleCount": len(properties.get("Rules") or []), + }, + ) + + +def _waf_cost(web_acls=1, rules=0, requests=0, *, + catalog=None, provider: str = "aws", region: str) -> float: + if catalog is None: + catalog = PricingCatalog() + total = 0.0 + if web_acls > 0: + r = catalog.query(provider, "AWSWAF", region, "WAF-WebACL-Month", web_acls) + if r and hasattr(r, "total_cost"): + total += r.total_cost + if rules > 0: + r = catalog.query(provider, "AWSWAF", region, "WAF-Rule-Month", rules) + if r and hasattr(r, "total_cost"): + total += r.total_cost + if requests > 0: + r = catalog.query(provider, "AWSWAF", region, "WAF-Request", requests) + if r and hasattr(r, "total_cost"): + total += r.total_cost + return total diff --git a/tests/test_waf.py b/tests/test_waf.py new file mode 100644 index 0000000..07ae9be --- /dev/null +++ b/tests/test_waf.py @@ -0,0 +1,181 @@ +"""Tests for the AWS WAFv2 web ACL resource handler (Issue #234).""" +import pytest +from infra_cost_model.pricing.catalog import PricingCatalog +from infra_cost_model.resources.waf import WAFv2WebACL, _waf_cost + + +class TestWAFAddress: + def test_from_address_terraform(self): + r = WAFv2WebACL.from_address("aws_wafv2_web_acl.admin") + assert r is not None and r.node_type == "routing" + + def test_from_address_pulumi(self): + r = WAFv2WebACL.from_address("aws.wafv2.WebAcl:edge") + assert r is not None and r.node_type == "routing" + + def test_from_address_cdk(self): + # CDK synthetic address format: ":" + r = WAFv2WebACL.from_address("AWS::WAFv2::WebACL:EdgeAcl") + assert r is not None and r.node_type == "routing" + + def test_from_address_unrelated(self): + assert WAFv2WebACL.from_address("aws_lb.public") is None + assert WAFv2WebACL.from_address("aws_kms_key.main") is None + # Classic WAF (aws_waf_web_acl) is a distinct, retired product; must not match. + assert WAFv2WebACL.from_address("aws_waf_web_acl.legacy") is None + # A rule group is a distinct resource, not the web ACL. + assert WAFv2WebACL.from_address("aws_wafv2_rule_group.rg") is None + + +class TestWAFExtract: + def test_extract_tf(self): + resource = { + "address": "aws_wafv2_web_acl.admin", + "values": { + "name": "admin-waf", + "scope": "REGIONAL", + "region": "us-east-1", + "default_action": [{"allow": [{}]}], + "rule": [{"name": "common"}, {"name": "bad-inputs"}], + }, + } + result = WAFv2WebACL.extract_tf(resource) + assert result.node_type == "routing" + assert result.provider == "aws" + assert result.service == "AWSWAF" + assert result.region == "us-east-1" + assert result.config["name"] == "admin-waf" + assert result.config["scope"] == "REGIONAL" + assert result.config["ruleCount"] == 2 + + def test_extract_tf_defaults(self): + resource = { + "address": "aws_wafv2_web_acl.simple", + "values": {"region": "us-west-2"}, + } + result = WAFv2WebACL.extract_tf(resource) + assert result.config["name"] is None + assert result.config["scope"] == "REGIONAL" + assert result.config["ruleCount"] == 0 + + def test_extract_pulumi(self): + resource = { + "id": "aws.wafv2.WebAcl:edge", + "inputs": { + "name": "edge-waf", + "scope": "CLOUDFRONT", + "region": "us-east-1", + "rules": [{"name": "a"}, {"name": "b"}, {"name": "c"}], + }, + } + result = WAFv2WebACL.extract_pulumi(resource) + assert result.service == "AWSWAF" + assert result.config["name"] == "edge-waf" + assert result.config["scope"] == "CLOUDFRONT" + assert result.config["ruleCount"] == 3 + + def test_extract_pulumi_defaults(self): + resource = {"id": "aws.wafv2.WebAcl:plain", "inputs": {}} + result = WAFv2WebACL.extract_pulumi(resource) + assert result.config["name"] is None + assert result.config["scope"] == "REGIONAL" + assert result.config["ruleCount"] == 0 + + def test_extract_cdk(self): + resource = { + "Type": "AWS::WAFv2::WebACL", + "LogicalId": "EdgeAcl", + "Properties": { + "Name": "cdk-waf", + "Scope": "CLOUDFRONT", + "Rules": [{"Name": "a"}], + }, + } + result = WAFv2WebACL.extract_cdk(resource) + assert result.service == "AWSWAF" + assert result.config["name"] == "cdk-waf" + assert result.config["scope"] == "CLOUDFRONT" + assert result.config["ruleCount"] == 1 + + def test_extract_cdk_defaults(self): + resource = { + "Type": "AWS::WAFv2::WebACL", + "LogicalId": "PlainAcl", + "Properties": {}, + } + result = WAFv2WebACL.extract_cdk(resource) + assert result.config["name"] is None + assert result.config["scope"] == "REGIONAL" + assert result.config["ruleCount"] == 0 + + +class TestWAFNodeAndMetrics: + def test_node_type(self): + assert WAFv2WebACL().node_type == "routing" + + def test_valid_metrics(self): + waf = WAFv2WebACL() + assert "webAcls" in waf.valid_metrics + assert "rules" in waf.valid_metrics + assert "requests" in waf.valid_metrics + + def test_catalog_metrics(self): + waf = WAFv2WebACL() + assert waf.catalog_metrics["webAcls"] == "WAF-WebACL-Month" + assert waf.catalog_metrics["rules"] == "WAF-Rule-Month" + assert waf.catalog_metrics["requests"] == "WAF-Request" + + +class TestWAFPricing: + def test_pricing_single_web_acl(self): + catalog = PricingCatalog(seed=True) + cost = _waf_cost(web_acls=1, rules=0, requests=0, + catalog=catalog, region="us-east-1") + assert cost == pytest.approx(5.00, rel=0.01) + + def test_pricing_rules(self): + catalog = PricingCatalog(seed=True) + # 1 web ACL ($5) + 4 rules ($4) = $9 + cost = _waf_cost(web_acls=1, rules=4, requests=0, + catalog=catalog, region="us-east-1") + assert cost == pytest.approx(9.00, rel=0.01) + + def test_pricing_requests(self): + catalog = PricingCatalog(seed=True) + # 1M requests at $0.60/million + cost = _waf_cost(web_acls=0, rules=0, requests=1_000_000, + catalog=catalog, region="us-east-1") + assert cost == pytest.approx(0.60, rel=0.01) + + def test_pricing_combined(self): + catalog = PricingCatalog(seed=True) + # 1 web ACL ($5) + 4 rules ($4) + 1M requests ($0.60) = $9.60 + cost = _waf_cost(web_acls=1, rules=4, requests=1_000_000, + catalog=catalog, region="us-east-1") + assert cost == pytest.approx(9.60, rel=0.01) + + def test_pricing_zero_usage(self): + catalog = PricingCatalog(seed=True) + cost = _waf_cost(web_acls=0, rules=0, requests=0, + catalog=catalog, region="us-east-1") + assert cost == 0.0 + + +class TestWAFRegistry: + def test_registry_from_address(self): + from infra_cost_model.resources.registry import ResourceRegistry + assert ResourceRegistry.from_address("aws_wafv2_web_acl.admin") == WAFv2WebACL + + def test_extract_via_registry(self): + from infra_cost_model.resources.registry import ResourceRegistry + resource = { + "address": "aws_wafv2_web_acl.admin", + "values": {"name": "admin-waf", "scope": "REGIONAL", "region": "us-east-1"}, + } + result = ResourceRegistry.extract( + "aws_wafv2_web_acl.admin", resource, "terraform" + ) + assert result is not None + assert result["provider"] == "aws" + assert result["service"] == "AWSWAF" + assert result["nodeType"] == "routing"