From 1b7e015e19a63135db94f1c5de2388c296598435 Mon Sep 17 00:00:00 2001 From: Jordan Bailey <44183232+bailey8@users.noreply.github.com> Date: Wed, 4 Aug 2021 01:45:41 -0400 Subject: [PATCH] Wafv2 initial Implementation --- moto/__init__.py | 1 + moto/backends.py | 1 + moto/wafv2/__init__.py | 7 ++ moto/wafv2/exceptions.py | 14 +++ moto/wafv2/models.py | 130 ++++++++++++++++++++++ moto/wafv2/responses.py | 49 ++++++++ moto/wafv2/urls.py | 11 ++ moto/wafv2/utils.py | 21 ++++ tests/test_wafv2/__init__.py | 0 tests/test_wafv2/test_helper_functions.py | 15 +++ tests/test_wafv2/test_server.py | 105 +++++++++++++++++ tests/test_wafv2/test_utils.py | 24 ++++ tests/test_wafv2/test_wafv2.py | 55 +++++++++ 13 files changed, 433 insertions(+) create mode 100644 moto/wafv2/__init__.py create mode 100644 moto/wafv2/exceptions.py create mode 100644 moto/wafv2/models.py create mode 100644 moto/wafv2/responses.py create mode 100644 moto/wafv2/urls.py create mode 100644 moto/wafv2/utils.py create mode 100644 tests/test_wafv2/__init__.py create mode 100644 tests/test_wafv2/test_helper_functions.py create mode 100644 tests/test_wafv2/test_server.py create mode 100644 tests/test_wafv2/test_utils.py create mode 100644 tests/test_wafv2/test_wafv2.py diff --git a/moto/__init__.py b/moto/__init__.py index 6750ff3ca..456bf3808 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -128,6 +128,7 @@ mock_mediastore = lazy_load(".mediastore", "mock_mediastore") mock_eks = lazy_load(".eks", "mock_eks") mock_mediastoredata = lazy_load(".mediastoredata", "mock_mediastoredata") mock_efs = lazy_load(".efs", "mock_efs") +mock_wafv2 = lazy_load(".wafv2", "mock_wafv2") # import logging # logging.getLogger('boto').setLevel(logging.CRITICAL) diff --git a/moto/backends.py b/moto/backends.py index b13708908..019ed26ca 100644 --- a/moto/backends.py +++ b/moto/backends.py @@ -85,6 +85,7 @@ BACKENDS = { "mediastore-data": ("mediastoredata", "mediastoredata_backends"), "eks": ("eks", "eks_backends"), "efs": ("efs", "efs_backends"), + "wafv2": ("wafv2", "wafv2_backends"), } diff --git a/moto/wafv2/__init__.py b/moto/wafv2/__init__.py new file mode 100644 index 000000000..e704ec2f4 --- /dev/null +++ b/moto/wafv2/__init__.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from .models import wafv2_backends +from ..core.models import base_decorator + +wafv2_backend = wafv2_backends["us-east-1"] +mock_wafv2 = base_decorator(wafv2_backends) diff --git a/moto/wafv2/exceptions.py b/moto/wafv2/exceptions.py new file mode 100644 index 000000000..54b8ddfda --- /dev/null +++ b/moto/wafv2/exceptions.py @@ -0,0 +1,14 @@ +from __future__ import unicode_literals +from moto.core.exceptions import RESTError + + +class WAFv2ClientError(RESTError): + code = 400 + + +class WAFV2DuplicateItemException(WAFv2ClientError): + def __init__(self): + super(WAFV2DuplicateItemException, self).__init__( + "WafV2DuplicateItem", + "AWS WAF could not perform the operation because some resource in your request is a duplicate of an existing one.", + ) diff --git a/moto/wafv2/models.py b/moto/wafv2/models.py new file mode 100644 index 000000000..8b234ca9e --- /dev/null +++ b/moto/wafv2/models.py @@ -0,0 +1,130 @@ +from __future__ import unicode_literals +from uuid import uuid4 +from boto3 import Session +from moto.core import BaseBackend, BaseModel +from moto.wafv2 import utils + +# from moto.ec2.models import elbv2_backends +from .utils import make_arn_for_wacl, pascal_to_underscores_dict +from .exceptions import WAFV2DuplicateItemException +from moto.core.utils import iso_8601_datetime_with_milliseconds +import datetime +from collections import OrderedDict + + +US_EAST_1_REGION = "us-east-1" +GLOBAL_REGION = "global" + + +class VisibilityConfig(BaseModel): + """ + https://docs.aws.amazon.com/waf/latest/APIReference/API_VisibilityConfig.html + """ + + def __init__( + self, metric_name, sampled_requests_enabled, cloud_watch_metrics_enabled + ): + self.cloud_watch_metrics_enabled = cloud_watch_metrics_enabled + self.metric_name = metric_name + self.sampled_requests_enabled = sampled_requests_enabled + + +class DefaultAction(BaseModel): + """ + https://docs.aws.amazon.com/waf/latest/APIReference/API_DefaultAction.html + """ + + def __init__(self, allow={}, block={}): + self.allow = allow + self.block = block + + +# TODO: Add remaining properties +class FakeWebACL(BaseModel): + """ + https://docs.aws.amazon.com/waf/latest/APIReference/API_WebACL.html + """ + + def __init__(self, name, arn, id, visibility_config, default_action): + self.name = name if name else utils.create_test_name("Mock-WebACL-name") + self.created_time = iso_8601_datetime_with_milliseconds(datetime.datetime.now()) + self.id = id + self.arn = arn + self.description = "Mock WebACL named {0}".format(self.name) + self.capacity = 3 + self.visibility_config = VisibilityConfig( + **pascal_to_underscores_dict(visibility_config) + ) + self.default_action = DefaultAction( + **pascal_to_underscores_dict(default_action) + ) + + def to_dict(self): + # Format for summary https://docs.aws.amazon.com/waf/latest/APIReference/API_CreateWebACL.html (response syntax section) + return { + "ARN": self.arn, + "Description": self.description, + "Id": self.id, + "LockToken": "Not Implemented", + "Name": self.name, + } + + +class WAFV2Backend(BaseBackend): + """ + https://docs.aws.amazon.com/waf/latest/APIReference/API_Operations_AWS_WAFV2.html + """ + + def __init__(self, region_name=None): + super(WAFV2Backend, self).__init__() + self.region_name = region_name + self.wacls = OrderedDict() # self.wacls[ARN] = FakeWacl + # TODO: self.load_balancers = OrderedDict() + + def reset(self): + region_name = self.region_name + self.__dict__ = {} + self.__init__(region_name) + + def create_web_acl(self, name, visibility_config, default_action, scope): + wacl_id = str(uuid4()) + arn = make_arn_for_wacl( + name=name, region_name=self.region_name, id=wacl_id, scope=scope + ) + if arn in self.wacls or self._is_duplicate_name(name): + raise WAFV2DuplicateItemException() + new_wacl = FakeWebACL(name, arn, wacl_id, visibility_config, default_action) + self.wacls[arn] = new_wacl + return new_wacl + + def list_web_acls(self): + return [wacl.to_dict() for wacl in self.wacls.values()] + + def _is_duplicate_name(self, name): + allWaclNames = set(wacl.name for wacl in self.wacls.values()) + return name in allWaclNames + + # TODO: This is how you link wacl to ALB + # @property + # def elbv2_backend(self): + # """ + # EC2 backend + + # :return: EC2 Backend + # :rtype: moto.ec2.models.EC2Backend + # """ + # return ec2_backends[self.region_name] + + +wafv2_backends = {} +wafv2_backends[GLOBAL_REGION] = WAFV2Backend( + GLOBAL_REGION +) # never used? cloudfront is global and uses us-east-1 +for region in Session().get_available_regions("waf-regional"): + wafv2_backends[region] = WAFV2Backend(region) +for region in Session().get_available_regions( + "waf-regional", partition_name="aws-us-gov" +): + wafv2_backends[region] = WAFV2Backend(region) +for region in Session().get_available_regions("waf-regional", partition_name="aws-cn"): + wafv2_backends[region] = WAFV2Backend(region) diff --git a/moto/wafv2/responses.py b/moto/wafv2/responses.py new file mode 100644 index 000000000..3055a83e0 --- /dev/null +++ b/moto/wafv2/responses.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals +import json +from moto.core.utils import amzn_request_id + +from moto.core.responses import BaseResponse +from .models import GLOBAL_REGION, wafv2_backends + + +class WAFV2Response(BaseResponse): + @property + def wafv2_backend(self): + return wafv2_backends[self.region] # default region is "us-east-1" + + @amzn_request_id + def create_web_acl(self): + """ https://docs.aws.amazon.com/waf/latest/APIReference/API_CreateWebACL.html (response syntax section) """ + + scope = self._get_param("Scope") + if scope == "CLOUDFRONT": + self.region = GLOBAL_REGION + name = self._get_param("Name") + body = json.loads(self.body) + web_acl = self.wafv2_backend.create_web_acl( + name, body["VisibilityConfig"], body["DefaultAction"], scope + ) + response = { + "Summary": web_acl.to_dict(), + } + response_headers = {"Content-Type": "application/json"} + return 200, response_headers, json.dumps(response) + + @amzn_request_id + def list_web_ac_ls(self): + """ https://docs.aws.amazon.com/waf/latest/APIReference/API_ListWebACLs.html (response syntax section) """ + + scope = self._get_param("Scope") + if scope == "CLOUDFRONT": + self.region = GLOBAL_REGION + all_web_acls = self.wafv2_backend.list_web_acls() + response = {"NextMarker": "Not Implemented", "WebACLs": all_web_acls} + response_headers = {"Content-Type": "application/json"} + return 200, response_headers, json.dumps(response) + + +# notes about region and scope +# --scope = CLOUDFRONT is ALWAYS us-east-1 (but we use "global" instead to differentiate between REGIONAL us-east-1) +# --scope = REGIONAL defaults to us-east-1, but could be anything if specified with --region= +# region is grabbed from the auth header, NOT from the body - even with --region flag +# The CLOUDFRONT wacls in aws console are located in us-east-1 but the us-east-1 REGIONAL wacls are not included diff --git a/moto/wafv2/urls.py b/moto/wafv2/urls.py new file mode 100644 index 000000000..84868860f --- /dev/null +++ b/moto/wafv2/urls.py @@ -0,0 +1,11 @@ +from __future__ import unicode_literals + +from .responses import WAFV2Response + +url_bases = [ + "https?://wafv2.(.+).amazonaws.com", +] + +url_paths = { + "{0}/": WAFV2Response.dispatch, +} diff --git a/moto/wafv2/utils.py b/moto/wafv2/utils.py new file mode 100644 index 000000000..31b925a3b --- /dev/null +++ b/moto/wafv2/utils.py @@ -0,0 +1,21 @@ +from moto.core import ACCOUNT_ID +from moto.core.utils import pascal_to_camelcase, camelcase_to_underscores + + +def make_arn_for_wacl(name, region_name, id, scope): + """https://docs.aws.amazon.com/waf/latest/developerguide/how-aws-waf-works.html - explains --scope (cloudfront vs regional)""" + + if scope == "REGIONAL": + scope = "regional" + elif scope == "CLOUDFRONT": + scope = "global" + return "arn:aws:wafv2:{}:{}:{}/webacl/{}/{}".format( + region_name, ACCOUNT_ID, scope, name, id + ) + + +def pascal_to_underscores_dict(original_dict): + outdict = {} + for k, v in original_dict.items(): + outdict[camelcase_to_underscores(pascal_to_camelcase(k))] = v + return outdict diff --git a/tests/test_wafv2/__init__.py b/tests/test_wafv2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_wafv2/test_helper_functions.py b/tests/test_wafv2/test_helper_functions.py new file mode 100644 index 000000000..2ab8fbd22 --- /dev/null +++ b/tests/test_wafv2/test_helper_functions.py @@ -0,0 +1,15 @@ +def CREATE_WEB_ACL_BODY(name: str, scope: str) -> dict: + return { + "Scope": scope, + "Name": name, + "DefaultAction": {"Allow": {}}, + "VisibilityConfig": { + "SampledRequestsEnabled": False, + "CloudWatchMetricsEnabled": False, + "MetricName": "idk", + }, + } + + +def LIST_WEB_ACL_BODY(scope: str) -> dict: + return {"Scope": scope} diff --git a/tests/test_wafv2/test_server.py b/tests/test_wafv2/test_server.py new file mode 100644 index 000000000..af441a9f6 --- /dev/null +++ b/tests/test_wafv2/test_server.py @@ -0,0 +1,105 @@ +from __future__ import unicode_literals +import json +import pytest + +import sure # noqa + +import boto3 +import botocore +from botocore.exceptions import ClientError +import moto.server as server +from moto import mock_wafv2 +from .test_helper_functions import CREATE_WEB_ACL_BODY, LIST_WEB_ACL_BODY +from moto.core import ACCOUNT_ID + +CREATE_WEB_ACL_HEADERS = { + "X-Amz-Target": "AWSWAF_20190729.CreateWebACL", + "Content-Type": "application/json", +} + + +LIST_WEB_ACL_HEADERS = { + "X-Amz-Target": "AWSWAF_20190729.ListWebACLs", + "Content-Type": "application/json", +} + + +@mock_wafv2 +def test_create_web_acl(): + backend = server.create_backend_app("wafv2") + test_client = backend.test_client() + + res = test_client.post( + "/", + headers=CREATE_WEB_ACL_HEADERS, + json=CREATE_WEB_ACL_BODY("John", "REGIONAL"), + ) + assert res.status_code == 200 + + web_acl = res.json["Summary"] + assert web_acl.get("Name") == "John" + assert web_acl.get("ARN").startswith( + "arn:aws:wafv2:us-east-1:{}:regional/webacl/John/".format(ACCOUNT_ID) + ) + + # Duplicate name - should raise error + res = test_client.post( + "/", + headers=CREATE_WEB_ACL_HEADERS, + json=CREATE_WEB_ACL_BODY("John", "REGIONAL"), + ) + assert res.status_code == 400 + assert ( + b"AWS WAF could not perform the operation because some resource in your request is a duplicate of an existing one." + in res.data + ) + assert b"WafV2DuplicateItem" in res.data + + res = test_client.post( + "/", + headers=CREATE_WEB_ACL_HEADERS, + json=CREATE_WEB_ACL_BODY("Carl", "CLOUDFRONT"), + ) + web_acl = res.json["Summary"] + assert web_acl.get("ARN").startswith( + "arn:aws:wafv2:global:{}:global/webacl/Carl/".format(ACCOUNT_ID) + ) + + +@mock_wafv2 +def test_list_web_ac_ls(): + backend = server.create_backend_app("wafv2") + test_client = backend.test_client() + + test_client.post( + "/", + headers=CREATE_WEB_ACL_HEADERS, + json=CREATE_WEB_ACL_BODY("John", "REGIONAL"), + ) + test_client.post( + "/", + headers=CREATE_WEB_ACL_HEADERS, + json=CREATE_WEB_ACL_BODY("JohnSon", "REGIONAL"), + ) + test_client.post( + "/", + headers=CREATE_WEB_ACL_HEADERS, + json=CREATE_WEB_ACL_BODY("Sarah", "CLOUDFRONT"), + ) + res = test_client.post( + "/", headers=LIST_WEB_ACL_HEADERS, json=LIST_WEB_ACL_BODY("REGIONAL") + ) + assert res.status_code == 200 + + web_acls = res.json["WebACLs"] + assert len(web_acls) == 2 + assert web_acls[0]["Name"] == "John" + assert web_acls[1]["Name"] == "JohnSon" + + res = test_client.post( + "/", headers=LIST_WEB_ACL_HEADERS, json=LIST_WEB_ACL_BODY("CLOUDFRONT") + ) + assert res.status_code == 200 + web_acls = res.json["WebACLs"] + assert len(web_acls) == 1 + assert web_acls[0]["Name"] == "Sarah" diff --git a/tests/test_wafv2/test_utils.py b/tests/test_wafv2/test_utils.py new file mode 100644 index 000000000..39bd255b2 --- /dev/null +++ b/tests/test_wafv2/test_utils.py @@ -0,0 +1,24 @@ +import random +import string +import uuid + +from moto.wafv2 import utils +from moto.wafv2.utils import make_arn_for_wacl +from moto.core import ACCOUNT_ID + + +def test_make_arn_for_wacl(): + uniqueID = str(uuid.uuid4()) + region = "us-east-1" + name = "testName" + scope = "REGIONAL" + arn = make_arn_for_wacl(name, region, uniqueID, scope) + assert arn == "arn:aws:wafv2:{}:{}:regional/webacl/{}/{}".format( + region, ACCOUNT_ID, name, uniqueID + ) + + scope = "CLOUDFRONT" + arn = make_arn_for_wacl(name, region, uniqueID, scope) + assert arn == "arn:aws:wafv2:{}:{}:global/webacl/{}/{}".format( + region, ACCOUNT_ID, name, uniqueID + ) diff --git a/tests/test_wafv2/test_wafv2.py b/tests/test_wafv2/test_wafv2.py new file mode 100644 index 000000000..fd066909d --- /dev/null +++ b/tests/test_wafv2/test_wafv2.py @@ -0,0 +1,55 @@ +from __future__ import unicode_literals +import json +import pytest + +import sure # noqa +import boto3 +from botocore.exceptions import ClientError +from moto import mock_wafv2 +from .test_helper_functions import CREATE_WEB_ACL_BODY, LIST_WEB_ACL_BODY +from moto.core import ACCOUNT_ID + + +@mock_wafv2 +def test_create_web_acl(): + + conn = boto3.client("wafv2", region_name="us-east-1") + res = conn.create_web_acl(**CREATE_WEB_ACL_BODY("John", "REGIONAL")) + web_acl = res["Summary"] + assert web_acl.get("Name") == "John" + assert web_acl.get("ARN").startswith( + "arn:aws:wafv2:us-east-1:{}:regional/webacl/John/".format(ACCOUNT_ID) + ) + # Duplicate name - should raise error + with pytest.raises(ClientError) as ex: + conn.create_web_acl(**CREATE_WEB_ACL_BODY("John", "REGIONAL")) + err = ex.value.response["Error"] + err["Message"].should.contain( + "AWS WAF could not perform the operation because some resource in your request is a duplicate of an existing one." + ) + err["Code"].should.equal("400") + + res = conn.create_web_acl(**CREATE_WEB_ACL_BODY("Carl", "CLOUDFRONT")) + web_acl = res["Summary"] + assert web_acl.get("ARN").startswith( + "arn:aws:wafv2:global:{}:global/webacl/Carl/".format(ACCOUNT_ID) + ) + + +@mock_wafv2 +def test_list_web_acl(): + + conn = boto3.client("wafv2", region_name="us-east-1") + conn.create_web_acl(**CREATE_WEB_ACL_BODY("Daphne", "REGIONAL")) + conn.create_web_acl(**CREATE_WEB_ACL_BODY("Penelope", "CLOUDFRONT")) + conn.create_web_acl(**CREATE_WEB_ACL_BODY("Sarah", "REGIONAL")) + res = conn.list_web_acls(**LIST_WEB_ACL_BODY("REGIONAL")) + web_acls = res["WebACLs"] + assert len(web_acls) == 2 + assert web_acls[0]["Name"] == "Daphne" + assert web_acls[1]["Name"] == "Sarah" + + res = conn.list_web_acls(**LIST_WEB_ACL_BODY("CLOUDFRONT")) + web_acls = res["WebACLs"] + assert len(web_acls) == 1 + assert web_acls[0]["Name"] == "Penelope"