├── .gitignore ├── images ├── subnets.png ├── nacl-rule.png ├── waf-alb-rule.png ├── deploy-to-aws.png ├── iam-dashboard.png ├── outputs-lambda.png ├── sns-confirmed.png ├── lambda-event-name.PNG ├── lambda-function.png ├── lambda-succeeded.png ├── solutiondiagram.png ├── lambda-select-test.png ├── waf-cloudfront-rule.png ├── lambda-configure-test.png ├── cloudformation-complete.png ├── waf-cloudfront-ipmatches.png └── cloudformation-specify-details.png ├── artifacts ├── prune_old_entries_wafv2.zip └── guardduty_to_acl_lambda_wafv2.zip ├── .github └── PULL_REQUEST_TEMPLATE.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── CONTRIBUTING.md ├── templates ├── gd2acl_test_event.json └── guarddutytoacl.template ├── scripts └── gd2acl-sync-check.py └── lambda ├── prune_old_entries.py └── guardduty_to_acl_lambda.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /images/subnets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/subnets.png -------------------------------------------------------------------------------- /images/nacl-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/nacl-rule.png -------------------------------------------------------------------------------- /images/waf-alb-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/waf-alb-rule.png -------------------------------------------------------------------------------- /images/deploy-to-aws.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/deploy-to-aws.png -------------------------------------------------------------------------------- /images/iam-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/iam-dashboard.png -------------------------------------------------------------------------------- /images/outputs-lambda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/outputs-lambda.png -------------------------------------------------------------------------------- /images/sns-confirmed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/sns-confirmed.png -------------------------------------------------------------------------------- /images/lambda-event-name.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/lambda-event-name.PNG -------------------------------------------------------------------------------- /images/lambda-function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/lambda-function.png -------------------------------------------------------------------------------- /images/lambda-succeeded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/lambda-succeeded.png -------------------------------------------------------------------------------- /images/solutiondiagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/solutiondiagram.png -------------------------------------------------------------------------------- /images/lambda-select-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/lambda-select-test.png -------------------------------------------------------------------------------- /images/waf-cloudfront-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/waf-cloudfront-rule.png -------------------------------------------------------------------------------- /images/lambda-configure-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/lambda-configure-test.png -------------------------------------------------------------------------------- /images/cloudformation-complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/cloudformation-complete.png -------------------------------------------------------------------------------- /images/waf-cloudfront-ipmatches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/waf-cloudfront-ipmatches.png -------------------------------------------------------------------------------- /artifacts/prune_old_entries_wafv2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/artifacts/prune_old_entries_wafv2.zip -------------------------------------------------------------------------------- /artifacts/guardduty_to_acl_lambda_wafv2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/artifacts/guardduty_to_acl_lambda_wafv2.zip -------------------------------------------------------------------------------- /images/cloudformation-specify-details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-guardduty-waf-acl/HEAD/images/cloudformation-specify-details.png -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## AWS GD2ACL 2 | 3 | ### WAFv2 Notes 4 | 5 | - You will need to have the artifacts (zip files in artifacts folder) staged on S3 and also update **ArtifactsBucket** and **ArtifactsPrefix** 6 | - Template no longer supports an existing IP sets. Regional and CloudFront IP sets are created automatically and can be included in your existing WAF rules and ACLs 7 | - New Regional and Global WAF ACLs can be created by changing parameter in the template from **False** to **True** 8 | - Lambda runtime has been updated to use Arm 9 | - Confirm expected functionality in non-production environment 10 | - Code WAFv1 is available in the [WAFv1 branch](https://github.com/aws-samples/amazon-guardduty-waf-acl/tree/wafv1/) 11 | 12 | --- 13 | 14 | ### How to use Amazon GuardDuty and AWS Web Application Firewall to Automatically Block Suspicious Hosts 15 | 16 | This solution uses Amazon GuardDuty to automatically update AWS Web Application Firewall Access Control Lists (WAF ACLs) and VPC Network Access Control Lists (NACLs) in response to GuardDuty findings. After GuardDuty detects a suspicious activity, the solution updates these resources to block communication from the suspicious host while additional investigation and remediation may be performed. 17 | 18 | ### Solution diagram 19 | 20 | ![architecture diagram](images/solutiondiagram.png) 21 | 22 | Here’s how the solution works, as shown in the diagram: 23 | 24 | 1. A GuardDuty Finding is raised with suspected malicious activity. 25 | 2. A CloudWatch Event is configured to filter for GuardDuty Finding type. 26 | 3. A Lambda function is invoked by the CloudWatch Event and parses the GuardDuty Finding. 27 | 4. State data for blocked hosts is stored in DynamoDB table. The Lambda function checks the state table for existing host entry. 28 | 5. The Lambda function creates a filter in a WAF ACL and in a VPC NACL. Older entries are aged out to create a “sliding window” of blocked hosts. 29 | 6. A notification email is sent via Amazon Simple Notification Service (SNS). 30 | 31 | 32 | ### License Summary 33 | 34 | This sample code is made available under a modified MIT license. See the LICENSE file. 35 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws-samples/amazon-guardduty-waf-acl/issues), or [recently closed](https://github.com/aws-samples/amazon-guardduty-waf-acl/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-samples/amazon-guardduty-waf-acl/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws-samples/amazon-guardduty-waf-acl/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /templates/gd2acl_test_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "region": "us-east-1", 3 | "detail": { 4 | "id": "02be5693b63eea1d67e6f321c6aee4f3", 5 | "type": "UnauthorizedAccess:EC2/SSHBruteForce", 6 | "resource": { 7 | "resourceType": "Instance", 8 | "instanceDetails": { 9 | "instanceId": "i-99999999", 10 | "instanceType": "m3.xlarge", 11 | "launchTime": "2016-08-02T02:05:06Z", 12 | "platform": null, 13 | "productCodes": [ 14 | { 15 | "productCodeId": "GeneratedFindingProductCodeId", 16 | "productCodeType": "GeneratedFindingProductCodeType" 17 | } 18 | ], 19 | "iamInstanceProfile": { 20 | "arn": "GeneratedFindingInstanceProfileArn", 21 | "id": "GeneratedFindingInstanceProfileId" 22 | }, 23 | "networkInterfaces": [ 24 | { 25 | "ipv6Addresses": [], 26 | "networkInterfaceId": "eni-bfcffe88", 27 | "privateDnsName": "GeneratedFindingPrivateDnsName", 28 | "privateIpAddress": "10.0.0.1", 29 | "privateIpAddresses": [ 30 | { 31 | "privateDnsName": "GeneratedFindingPrivateName", 32 | "privateIpAddress": "10.0.0.1" 33 | } 34 | ], 35 | "subnetId": "Replace with valid SubnetID", 36 | "vpcId": "GeneratedFindingVPCId", 37 | "securityGroups": [ 38 | { 39 | "groupName": "GeneratedFindingSecurityGroupName", 40 | "groupId": "GeneratedFindingSecurityId" 41 | } 42 | ], 43 | "publicDnsName": "GeneratedFindingPublicDNSName", 44 | "publicIp": "198.51.100.0" 45 | } 46 | ], 47 | "tags": [ 48 | { 49 | "key": "GeneratedFindingInstaceTag1", 50 | "value": "GeneratedFindingInstaceValue1" 51 | }, 52 | { 53 | "key": "GeneratedFindingInstaceTag2", 54 | "value": "GeneratedFindingInstaceTagValue2" 55 | }, 56 | { 57 | "key": "GeneratedFindingInstaceTag3", 58 | "value": "GeneratedFindingInstaceTagValue3" 59 | }, 60 | { 61 | "key": "GeneratedFindingInstaceTag4", 62 | "value": "GeneratedFindingInstaceTagValue4" 63 | }, 64 | { 65 | "key": "GeneratedFindingInstaceTag5", 66 | "value": "GeneratedFindingInstaceTagValue5" 67 | }, 68 | { 69 | "key": "GeneratedFindingInstaceTag6", 70 | "value": "GeneratedFindingInstaceTagValue6" 71 | }, 72 | { 73 | "key": "GeneratedFindingInstaceTag7", 74 | "value": "GeneratedFindingInstaceTagValue7" 75 | }, 76 | { 77 | "key": "GeneratedFindingInstaceTag8", 78 | "value": "GeneratedFindingInstaceTagValue8" 79 | }, 80 | { 81 | "key": "GeneratedFindingInstaceTag9", 82 | "value": "GeneratedFindingInstaceTagValue9" 83 | } 84 | ], 85 | "instanceState": "running", 86 | "availabilityZone": "GeneratedFindingInstaceAvailabilityZone", 87 | "imageId": "ami-99999999", 88 | "imageDescription": "GeneratedFindingInstaceImageDescription" 89 | } 90 | }, 91 | "service": { 92 | "serviceName": "guardduty", 93 | "action": { 94 | "actionType": "NETWORK_CONNECTION", 95 | "networkConnectionAction": { 96 | "connectionDirection": "INBOUND", 97 | "remoteIpDetails": { 98 | "ipAddressV4": "198.51.100.0", 99 | "organization": { 100 | "asn": "-1", 101 | "asnOrg": "GeneratedFindingASNOrg", 102 | "isp": "GeneratedFindingISP", 103 | "org": "GeneratedFindingORG" 104 | }, 105 | "country": { 106 | "countryName": "GeneratedFindingCountryName" 107 | }, 108 | "city": { 109 | "cityName": "GeneratedFindingCityName" 110 | }, 111 | "geoLocation": { 112 | "lat": 0, 113 | "lon": 0 114 | } 115 | }, 116 | "remotePortDetails": { 117 | "port": 32794, 118 | "portName": "Unknown" 119 | }, 120 | "localPortDetails": { 121 | "port": 22, 122 | "portName": "SSH" 123 | }, 124 | "protocol": "TCP", 125 | "blocked": false 126 | } 127 | }, 128 | "resourceRole": "TARGET", 129 | "additionalInfo": { 130 | "sample": true 131 | }, 132 | "eventFirstSeen": "2018-05-11T14:56:39.976Z", 133 | "eventLastSeen": "2018-05-11T14:56:39.976Z", 134 | "archived": false, 135 | "count": 1 136 | }, 137 | "severity": 2, 138 | "createdAt": "2018-05-11T14:56:39.976Z", 139 | "updatedAt": "2018-05-11T14:56:39.976Z", 140 | "title": "198.51.100.0 is performing SSH brute force attacks against i-99999999. ", 141 | "description": "198.51.100.0 is performing SSH brute force attacks against i-99999999. Brute force attacks are used to gain unauthorized access to your instance by guessing the SSH password." 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /scripts/gd2acl-sync-check.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # MIT No Attribution 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | # software and associated documentation files (the "Software"), to deal in the Software 6 | # without restriction, including without limitation the rights to use, copy, modify, 7 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | 17 | import boto3 18 | import math 19 | import time 20 | import json 21 | import datetime 22 | import logging 23 | import os 24 | import sys 25 | from boto3.dynamodb.conditions import Key, Attr 26 | from botocore.exceptions import ClientError 27 | 28 | logger = logging.getLogger() 29 | logger.setLevel(logging.INFO) 30 | logging.basicConfig(stream=sys.stdout, level=logging.INFO) 31 | 32 | #====================================================================================================================== 33 | # Variables 34 | #====================================================================================================================== 35 | 36 | ACLMETATABLE = "GuardDutytoACL-GuardDutytoACLDDBTable-ID" 37 | AWS_REGION = "us-east-1" 38 | 39 | #====================================================================================================================== 40 | # Auxiliary Functions 41 | #====================================================================================================================== 42 | 43 | # used to color text 44 | class bcolors: 45 | OKGREEN = '\033[92m' 46 | WARNING = '\033[93m' 47 | FAIL = '\033[91m' 48 | ENDC = '\033[0m' 49 | 50 | # validate command line 51 | if len(sys.argv) != 2: 52 | print('Usage: gd2acl-sync-check.py '); 53 | exit(1); 54 | 55 | # set target vpc nacl 56 | targnacl = sys.argv[1]; 57 | 58 | def get_netacl_id(subnet_id): 59 | 60 | try: 61 | ec2 = boto3.client('ec2') 62 | response = ec2.describe_network_acls( 63 | Filters=[ 64 | { 65 | 'Name': 'association.subnet-id', 66 | 'Values': [ 67 | subnet_id, 68 | ] 69 | } 70 | ] 71 | ) 72 | 73 | 74 | netacls = response['NetworkAcls'][0]['Associations'] 75 | 76 | for i in netacls: 77 | if i['SubnetId'] == subnet_id: 78 | netaclid = i['NetworkAclId'] 79 | 80 | return netaclid 81 | except Exception as e: 82 | return [] 83 | 84 | 85 | def get_nacl_rules(netacl_id): 86 | ec2 = boto3.client('ec2') 87 | response = ec2.describe_network_acls( 88 | NetworkAclIds=[ 89 | netacl_id, 90 | ] 91 | ) 92 | 93 | naclrules = [] 94 | 95 | for i in response['NetworkAcls'][0]['Entries']: 96 | naclrules.append(i['RuleNumber']) 97 | 98 | naclrulesf = list(filter(lambda x: 71 <= x <= 80, naclrules)) 99 | 100 | return naclrulesf 101 | 102 | 103 | def get_nacl_meta(netacl_id): 104 | ddb = boto3.resource('dynamodb') 105 | table = ddb.Table(ACLMETATABLE) 106 | ec2 = boto3.client('ec2') 107 | response = ec2.describe_network_acls( 108 | NetworkAclIds=[ 109 | netacl_id, 110 | ] 111 | ) 112 | 113 | # Get entries in DynamoDB table 114 | ddbresponse = table.scan() 115 | ddbentries = response['Items'] 116 | 117 | netacl = ddbresponse['NetworkAcls'][0]['Entries'] 118 | naclentries = [] 119 | 120 | for i in netacl: 121 | entries.append(i) 122 | 123 | return naclentries 124 | 125 | 126 | def check_nacl(netacl_id, region): 127 | logger.info("checking nacl, netacl_id=%s." % (netacl_id)) 128 | 129 | ddb = boto3.resource('dynamodb') 130 | table = ddb.Table(ACLMETATABLE) 131 | 132 | # Get current NACL entries in DDB 133 | response = table.query( 134 | KeyConditionExpression=Key('NetACLId').eq(netacl_id) 135 | ) 136 | 137 | # Get all the entries for NACL 138 | naclentries = response['Items'] 139 | 140 | # Get the range and check the state 141 | if naclentries: 142 | rulecount = response['Count'] 143 | rulerange = list(range(71, 81)) 144 | 145 | ddbrulerange = [] 146 | naclrulerange = get_nacl_rules(netacl_id) 147 | 148 | for i in naclentries: 149 | ddbrulerange.append(int(i['RuleNo'])) 150 | 151 | ddbrulerange.sort() 152 | naclrulerange.sort() 153 | 154 | synccheck = set(naclrulerange).symmetric_difference(ddbrulerange) 155 | 156 | if ddbrulerange != naclrulerange: 157 | logger.info("log -- current DDB entries, %s." % (ddbrulerange)) 158 | logger.info("log -- current NACL entries, %s." % (naclrulerange)) 159 | logger.info("log -- rule count, %s." % (rulecount)) 160 | print(bcolors.FAIL + 'Rule state mismatch for NACL, %s' % (sorted(synccheck)) + bcolors.ENDC) 161 | else: 162 | logger.info("log -- current DDB entries, %s." % (ddbrulerange)) 163 | logger.info("log -- current NACL entries, %s." % (naclrulerange)) 164 | logger.info("log -- rule count for NACL %s is %s." % (netacl_id, rulecount)) 165 | print(bcolors.OKGREEN + 'Rule state is OK for NACL, %s.' % (netacl_id) + bcolors.ENDC) 166 | 167 | if response['ResponseMetadata']['HTTPStatusCode'] == 200: 168 | return True 169 | else: 170 | return False 171 | 172 | #====================================================================================================================== 173 | # Run main function 174 | #====================================================================================================================== 175 | 176 | try: 177 | check_nacl(targnacl, AWS_REGION) 178 | 179 | except Exception as e: 180 | logger.error('Something went wrong.') 181 | raise -------------------------------------------------------------------------------- /lambda/prune_old_entries.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # MIT No Attribution 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | # software and associated documentation files (the "Software"), to deal in the Software 6 | # without restriction, including without limitation the rights to use, copy, modify, 7 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | 17 | import boto3 18 | import math 19 | import time 20 | import json 21 | import datetime 22 | import logging 23 | import os 24 | from boto3.dynamodb.conditions import Key, Attr 25 | 26 | logger = logging.getLogger() 27 | logger.setLevel(logging.INFO) 28 | 29 | #====================================================================================================================== 30 | # Variables 31 | #====================================================================================================================== 32 | 33 | API_CALL_NUM_RETRIES = 1 34 | ACLMETATABLE = os.environ['ACLMETATABLE'] 35 | RETENTION = os.environ['RETENTION'] 36 | REGIONAL_IP_SET = os.environ['REGIONAL_IP_SET'] 37 | 38 | RegionalIpSet = REGIONAL_IP_SET.split("|") 39 | 40 | CloudFrontEnable = os.environ['CLOUDFRONT_ENABLE'] 41 | 42 | if CloudFrontEnable == "true": 43 | CLOUDFRONT_IP_SET = os.environ['CLOUDFRONT_IP_SET'] 44 | CloudFrontIpSet = CLOUDFRONT_IP_SET.split("|") 45 | elif CloudFrontEnable == "false": 46 | CloudFrontIpSet = [] 47 | 48 | 49 | #====================================================================================================================== 50 | # Auxiliary Functions 51 | #====================================================================================================================== 52 | 53 | 54 | def get_ip_set(ip_set_name, ip_set_id, ip_set_scope): 55 | client = boto3.client('wafv2') 56 | response = client.get_ip_set( 57 | Name = ip_set_name, 58 | Scope = ip_set_scope, 59 | Id = ip_set_id 60 | ) 61 | return response 62 | 63 | 64 | def get_ddb_ips(): 65 | ddb = boto3.resource('dynamodb') 66 | table = ddb.Table(ACLMETATABLE) 67 | data = table.scan(FilterExpression=Attr('Region').eq(os.environ['AWS_REGION'])) 68 | response = [] 69 | for i in data['Items']: 70 | response.append(i['HostIp'] + "/32") 71 | logger.info("log -- hosts in ddb: %s" % (response)) 72 | return response 73 | 74 | 75 | def waf_update_ip_set(ip_set_name, ip_set_id, ip_set_scope, source_ips): 76 | logger.info('creating waf object') 77 | waf = boto3.client('wafv2') 78 | 79 | for attempt in range(API_CALL_NUM_RETRIES): 80 | logger.info('type of IPset: %s' % ip_set_id ) 81 | try: 82 | response = waf.update_ip_set( 83 | Name = ip_set_name, 84 | Id = ip_set_id, 85 | Scope = ip_set_scope, 86 | LockToken = get_ip_set(ip_set_name, ip_set_id, ip_set_scope)['LockToken'], 87 | Addresses=source_ips 88 | ) 89 | logger.info(response) 90 | logger.info("log -- waf_update_ip_set %s IPs %s - type %s successfully..." % (ip_set_id, source_ips, ip_set_scope)) 91 | except Exception as e: 92 | logger.error(e) 93 | delay = math.pow(2, attempt) 94 | logger.info("log -- waf_update_ip_set retrying in %d seconds..." % (delay)) 95 | time.sleep(delay) 96 | else: 97 | break 98 | else: 99 | logger.error("log -- waf_update_ip_set failed ALL attempts to call API") 100 | 101 | 102 | def waf_update_ip_sets(): 103 | ddb_ips = get_ddb_ips() 104 | if ddb_ips: 105 | logger.info('log -- adding Regional and CloudFront WAF ip entries') 106 | waf_update_ip_set(RegionalIpSet[0], RegionalIpSet[1], RegionalIpSet[2], ddb_ips) 107 | if CloudFrontEnable == "true": 108 | waf_update_ip_set(CloudFrontIpSet[0], CloudFrontIpSet[1], CloudFrontIpSet[2], ddb_ips) 109 | 110 | 111 | def delete_netacl_rule(netacl_id, rule_no): 112 | 113 | ec2 = boto3.resource('ec2') 114 | network_acl = ec2.NetworkAcl(netacl_id) 115 | 116 | try: 117 | response = network_acl.delete_entry( 118 | Egress=False, 119 | RuleNumber=int(rule_no) 120 | ) 121 | if response['ResponseMetadata']['HTTPStatusCode'] == 200: 122 | logger.info('log -- delete_netacl_rule successful') 123 | return True 124 | else: 125 | logger.error('log -- delete_netacl_rule FAILED') 126 | logger.info(response) 127 | return False 128 | except Exception as e: 129 | logger.error(e) 130 | 131 | 132 | def delete_ddb_rule(netacl_id, created_at): 133 | 134 | ddb = boto3.resource('dynamodb') 135 | table = ddb.Table(ACLMETATABLE) 136 | timestamp = int(time.time()) 137 | 138 | response = table.delete_item( 139 | Key={ 140 | 'NetACLId': netacl_id, 141 | 'CreatedAt': int(created_at) 142 | } 143 | ) 144 | 145 | if response['ResponseMetadata']['HTTPStatusCode'] == 200: 146 | logger.info('log -- delete_ddb_rule successful') 147 | return True 148 | else: 149 | logger.error('log -- delete_ddb_rule FAILED') 150 | logger.info(response['ResponseMetadata']) 151 | return False 152 | 153 | 154 | #====================================================================================================================== 155 | # Lambda Entry Point 156 | #====================================================================================================================== 157 | 158 | 159 | def lambda_handler(event, context): 160 | 161 | #logger.info("log -- Event: %s " % json.dumps(event)) 162 | 163 | try: 164 | # timestamp is calculated in seconds 165 | expire_time = int(time.time()) - (int(RETENTION)*60) 166 | logger.info("log -- expire_time = %s" % expire_time) 167 | 168 | #scan the ddb table to find expired records 169 | ddb = boto3.resource('dynamodb') 170 | table = ddb.Table(ACLMETATABLE) 171 | response = table.scan(FilterExpression=Attr('CreatedAt').lt(expire_time) & Attr('Region').eq(os.environ['AWS_REGION'])) 172 | 173 | if response['Items']: 174 | logger.info("log -- attempting to prune entries, %s." % (response)['Items']) 175 | 176 | # process each expired record 177 | for item in response['Items']: 178 | logger.info("deleting item: %s" %item) 179 | logger.info("HostIp %s" %item['HostIp']) 180 | HostIp = item['HostIp'] 181 | try: 182 | logger.info('log -- deleting netacl rule') 183 | delete_netacl_rule(item['NetACLId'], item['RuleNo']) 184 | 185 | # check if IP is also recorded in a fresh finding, don't remove IP from blocklist in that case 186 | response_nonexpired = table.scan( FilterExpression=Attr('CreatedAt').gt(expire_time) & Attr('HostIp').eq(HostIp) ) 187 | logger.info('log -- deleting dynamodb item') 188 | if len(response_nonexpired['Items']) == 0: 189 | delete_ddb_rule(item['NetACLId'], item['CreatedAt']) 190 | # no fresher entry found for that IP 191 | 192 | except Exception as e: 193 | logger.error(e) 194 | logger.error('log -- could not delete item') 195 | 196 | # Update WAF IP Sets 197 | logger.info('log -- update CloudFront Ip set %s and Regional IP set %s.' % (CLOUDFRONT_IP_SET, REGIONAL_IP_SET)) 198 | waf_update_ip_sets() 199 | 200 | logger.info("Pruning Completed") 201 | 202 | else: 203 | logger.info("log -- no etntries older than %s hours... exiting GD2ACL pruning." % (int(RETENTION)/60)) 204 | 205 | except Exception as e: 206 | logger.error('something went wrong') 207 | raise 208 | -------------------------------------------------------------------------------- /templates/guarddutytoacl.template: -------------------------------------------------------------------------------- 1 | --- 2 | AWSTemplateFormatVersion: '2010-09-09' 3 | Description: Demonstrates how to use GuardDuty Findings to automate WAFv2 ACL and VPC NACL entries. 4 | The template installs a Lambda function that updates an AWS WAFv2 IP Set and VPC NACL. 5 | 6 | Parameters: 7 | Retention: 8 | Description: How long to retain IP addresses in the blocklist (in minutes). Default is 12 hours, minimum is 5 minutes and maximum one week (10080 minutes) 9 | Type: Number 10 | Default: 720 11 | MinValue: 5 12 | MaxValue: 10080 13 | ConstraintDescription: Minimum of 5 minutes and maximum of 10080 (one week). 14 | AdminEmail: 15 | Description: Email address to receive notifications. Must be a valid email address. 16 | Type: String 17 | AllowedPattern: ^(?:[a-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$ 18 | EnableCloudFront: 19 | Type: String 20 | Default: True 21 | AllowedValues: 22 | - True 23 | - False 24 | Description: CloudFront resources (WebACL and IP sets) will be created automatically. 25 | ArtifactsBucket: 26 | Description: S3 bucket with artifact files (Lambda functions, templates, html files, etc.). 27 | Type: String 28 | AllowedPattern: ^[0-9a-zA-Z]+([0-9a-zA-Z-]*[0-9a-zA-Z])*$ 29 | ConstraintDescription: ArtifactsBucket S3 bucket name can include numbers, lowercase letters, uppercase letters, and hyphens (-). 30 | It cannot start or end with a hyphen (-). 31 | ArtifactsPrefix: 32 | Description: Path in the S3 folder, e.g. "folder_name/" containing artifact files. Leave empty if artifacts are in root of S3 bucket. 33 | Type: String 34 | Default: "" 35 | AllowedPattern: ^[0-9a-zA-Z-/|]*$ 36 | ConstraintDescription: ArtifactsPrefix key prefix can include numbers, lowercase letters, uppercase letters, hyphens (-), 37 | and forward slash (/). Leave empty if artifacts are in root of S3 bucket. 38 | 39 | Metadata: 40 | AWS::CloudFormation::Interface: 41 | ParameterGroups: 42 | - Label: 43 | default: GD2ACL Configuration 44 | Parameters: 45 | - AdminEmail 46 | - Retention 47 | - EnableCloudFront 48 | - Label: 49 | default: Artifact Configuration 50 | Parameters: 51 | - ArtifactsBucket 52 | - ArtifactsPrefix 53 | 54 | ParameterLabels: 55 | AdminEmail: 56 | default: Notification email (REQUIRED) 57 | Retention: 58 | default: Retention time in minutes 59 | EnableCloudFront: 60 | default: Create CloudFront Web ACL and IP set? 61 | ArtifactsBucket: 62 | default: S3 bucket for artifacts 63 | ArtifactsPrefix: 64 | default: S3 path to artifacts 65 | 66 | Conditions: 67 | # Create CloudFront resources? 68 | CloudFrontEnable: !Equals [True, !Ref EnableCloudFront ] 69 | 70 | Resources: 71 | 72 | GuardDutytoACLLambda: 73 | Type: AWS::Lambda::Function 74 | Properties: 75 | Description: "GuardDuty to ACL Function" 76 | Architectures: 77 | - arm64 78 | Handler : "guardduty_to_acl_lambda.lambda_handler" 79 | MemorySize: 1024 80 | Timeout: 300 81 | Role: !GetAtt GuardDutytoACLRole.Arn 82 | Runtime : "python3.11" 83 | Environment: 84 | Variables: 85 | ACLMETATABLE: !Ref GuardDutytoACLDDBTable 86 | REGIONAL_IP_SET: !Ref RegionalBlocklistIPSetV4 87 | CLOUDFRONT_IP_SET: !If [CloudFrontEnable, !Ref CloudFrontBlocklistIPSetV4, ''] 88 | SNSTOPIC: !Ref GuardDutytoACLSNSTopic 89 | CLOUDFRONT_ENABLE: !Ref EnableCloudFront 90 | Code: 91 | S3Bucket: !Sub ${ArtifactsBucket} 92 | S3Key: !Sub ${ArtifactsPrefix}guardduty_to_acl_lambda_wafv2.zip 93 | 94 | GuardDutytoACLRole: 95 | Type: AWS::IAM::Role 96 | Properties: 97 | AssumeRolePolicyDocument: 98 | Version: "2012-10-17" 99 | Statement: 100 | - 101 | Effect: "Allow" 102 | Principal: 103 | Service: 104 | - "lambda.amazonaws.com" 105 | Action: 106 | "sts:AssumeRole" 107 | Path: "/" 108 | 109 | GuardDutytoACLPolicy: 110 | Type: AWS::IAM::Policy 111 | Properties: 112 | PolicyName: 113 | Fn::Join: 114 | - '-' 115 | - [ !Ref "AWS::Region", 'guardduty-to-nacl-wafipset' ] 116 | PolicyDocument: 117 | Version: 2012-10-17 118 | Statement: 119 | - 120 | Effect: Allow 121 | Action: 122 | - wafv2:GetIPSet 123 | - wafv2:UpdateIPSet 124 | Resource: 125 | Fn::If: 126 | - CloudFrontEnable 127 | - - !GetAtt CloudFrontBlocklistIPSetV4.Arn 128 | - !GetAtt RegionalBlocklistIPSetV4.Arn 129 | - - !GetAtt RegionalBlocklistIPSetV4.Arn 130 | - 131 | Effect: "Allow" 132 | Action: 133 | - "ec2:Describe*" 134 | - "ec2:*NetworkAcl*" 135 | Resource: "*" 136 | - 137 | Effect: "Allow" 138 | Action: 139 | - "logs:CreateLogGroup" 140 | - "logs:CreateLogStream" 141 | - "logs:PutLogEvents" 142 | Resource: "arn:aws:logs:*:*:*" 143 | - 144 | Effect: Allow 145 | Action: 146 | - dynamodb:GetItem 147 | - dynamodb:PutItem 148 | - dynamodb:Query 149 | - dynamodb:Scan 150 | - dynamodb:DeleteItem 151 | Resource: !GetAtt GuardDutytoACLDDBTable.Arn 152 | - 153 | Effect: Allow 154 | Action: 155 | - sns:Publish 156 | Resource: !Ref GuardDutytoACLSNSTopic 157 | Roles: 158 | - 159 | Ref: "GuardDutytoACLRole" 160 | 161 | # GuardDuty CloudWatch Event - For GuardDuty Finding: 162 | GuardDutytoACLEvent: 163 | Type: "AWS::Events::Rule" 164 | Properties: 165 | Description: "GuardDuty Malicious Host Events" 166 | EventPattern: 167 | source: 168 | - aws.guardduty 169 | detail: 170 | type: 171 | - prefix: "UnauthorizedAccess:EC2" 172 | - prefix: "Recon:EC2" 173 | - prefix: "Trojan:EC2" 174 | - prefix: "Backdoor:EC2" 175 | - prefix: "Impact:EC2" 176 | - prefix: "CryptoCurrency:EC2" 177 | - prefix: "Behavior:EC2" 178 | 179 | State: "ENABLED" 180 | Targets: 181 | - 182 | Arn: !GetAtt GuardDutytoACLLambda.Arn 183 | Id: "GuardDutyEvent-Lambda-Trigger" 184 | 185 | GuardDutytoACLInvokePermissions: 186 | Type: "AWS::Lambda::Permission" 187 | Properties: 188 | FunctionName: !Ref "GuardDutytoACLLambda" 189 | Action: "lambda:InvokeFunction" 190 | SourceArn: !GetAtt GuardDutytoACLEvent.Arn 191 | Principal: "events.amazonaws.com" 192 | 193 | GuardDutytoACLDDBTable: 194 | Type: "AWS::DynamoDB::Table" 195 | Properties: 196 | AttributeDefinitions: 197 | - 198 | AttributeName: "NetACLId" 199 | AttributeType: "S" 200 | - 201 | AttributeName: "CreatedAt" 202 | AttributeType: "N" 203 | KeySchema: 204 | - 205 | AttributeName: "NetACLId" 206 | KeyType: "HASH" 207 | - 208 | AttributeName: "CreatedAt" 209 | KeyType: "RANGE" 210 | ProvisionedThroughput: 211 | ReadCapacityUnits: "5" 212 | WriteCapacityUnits: "5" 213 | 214 | CloudFrontBlocklistIPSetV4: 215 | Condition: CloudFrontEnable 216 | Type: 'AWS::WAFv2::IPSet' 217 | Properties: 218 | Description: GD2ACLIPSetV4 219 | Scope: CLOUDFRONT 220 | IPAddressVersion: IPV4 221 | Addresses: 222 | - 127.0.0.0/8 223 | 224 | CloudFrontBlocklistIPSetV6: 225 | Condition: CloudFrontEnable 226 | Type: 'AWS::WAFv2::IPSet' 227 | Properties: 228 | Description: GD2ACLIPSetV6 229 | Scope: CLOUDFRONT 230 | IPAddressVersion: IPV6 231 | Addresses: 232 | - ::1/128 233 | 234 | CloudFrontBlocklistWebACL: 235 | Condition: CloudFrontEnable 236 | Type: AWS::WAFv2::WebACL 237 | Properties: 238 | Scope: CLOUDFRONT 239 | DefaultAction: 240 | Allow: {} 241 | VisibilityConfig: 242 | SampledRequestsEnabled: true 243 | CloudWatchMetricsEnabled: true 244 | MetricName: GD2ACLCloudFrontBlocklistWebACL 245 | Rules: 246 | - Name: CloudFrontBlocklistIPSetRule 247 | Priority: 1 248 | Action: 249 | Block: {} 250 | VisibilityConfig: 251 | SampledRequestsEnabled: true 252 | CloudWatchMetricsEnabled: true 253 | MetricName: CloudFrontBlockIPMetric 254 | Statement: 255 | OrStatement: 256 | Statements: 257 | - IPSetReferenceStatement: 258 | Arn: !If [CloudFrontEnable, !GetAtt CloudFrontBlocklistIPSetV4.Arn, ''] 259 | - IPSetReferenceStatement: 260 | Arn: !GetAtt CloudFrontBlocklistIPSetV6.Arn 261 | 262 | RegionalBlocklistIPSetV4: 263 | Type: 'AWS::WAFv2::IPSet' 264 | Properties: 265 | Description: GD2ACLIPSetV4 266 | Scope: REGIONAL 267 | IPAddressVersion: IPV4 268 | Addresses: 269 | - 127.0.0.0/8 270 | 271 | RegionalBlocklistIPSetV6: 272 | Type: 'AWS::WAFv2::IPSet' 273 | Properties: 274 | Description: GD2ACLIPSetV6 275 | Scope: REGIONAL 276 | IPAddressVersion: IPV6 277 | Addresses: 278 | - ::1/128 279 | 280 | RegionalBlocklistWebACL: 281 | Type: AWS::WAFv2::WebACL 282 | Properties: 283 | Scope: REGIONAL 284 | DefaultAction: 285 | Allow: {} 286 | VisibilityConfig: 287 | SampledRequestsEnabled: true 288 | CloudWatchMetricsEnabled: true 289 | MetricName: GD2ACLRegionalBlocklistWebACL 290 | Rules: 291 | - Name: RegionalBlocklistIPSetRule 292 | Priority: 1 293 | Action: 294 | Block: {} 295 | VisibilityConfig: 296 | SampledRequestsEnabled: true 297 | CloudWatchMetricsEnabled: true 298 | MetricName: RegionalBlockIPMetric 299 | Statement: 300 | OrStatement: 301 | Statements: 302 | - IPSetReferenceStatement: 303 | Arn: !GetAtt RegionalBlocklistIPSetV4.Arn 304 | - IPSetReferenceStatement: 305 | Arn: !GetAtt RegionalBlocklistIPSetV6.Arn 306 | 307 | PruneOldEntriesLambda: 308 | Type: AWS::Lambda::Function 309 | Properties: 310 | Description: "Prune old entries in WAF ACL and NACLs" 311 | Architectures: 312 | - arm64 313 | Handler : "prune_old_entries.lambda_handler" 314 | MemorySize: 1024 315 | Timeout: 300 316 | Role: !GetAtt PruneOldEntriesRole.Arn 317 | Runtime : "python3.11" 318 | Environment: 319 | Variables: 320 | ACLMETATABLE: !Ref GuardDutytoACLDDBTable 321 | REGIONAL_IP_SET: !Ref RegionalBlocklistIPSetV4 322 | CLOUDFRONT_IP_SET: !If [CloudFrontEnable, !Ref CloudFrontBlocklistIPSetV4, ''] 323 | RETENTION: !Ref Retention 324 | CLOUDFRONT_ENABLE: !Ref EnableCloudFront 325 | Code: 326 | S3Bucket: !Sub ${ArtifactsBucket} 327 | S3Key: !Sub ${ArtifactsPrefix}prune_old_entries_wafv2.zip 328 | 329 | PruneOldEntriesRole: 330 | Type: AWS::IAM::Role 331 | Properties: 332 | AssumeRolePolicyDocument: 333 | Version: "2012-10-17" 334 | Statement: 335 | - 336 | Effect: "Allow" 337 | Principal: 338 | Service: 339 | - "lambda.amazonaws.com" 340 | Action: 341 | "sts:AssumeRole" 342 | Path: "/" 343 | 344 | PruneOldEntriesPolicy: 345 | Type: AWS::IAM::Policy 346 | Properties: 347 | PolicyName: 348 | Fn::Join: 349 | - '-' 350 | - [ !Ref "AWS::Region", 'prune-old-entries' ] 351 | PolicyDocument: 352 | Version: 2012-10-17 353 | Statement: 354 | - 355 | Effect: Allow 356 | Action: 357 | - wafv2:GetIPSet 358 | - wafv2:UpdateIPSet 359 | Resource: 360 | Fn::If: 361 | - CloudFrontEnable 362 | - - !GetAtt CloudFrontBlocklistIPSetV4.Arn 363 | - !GetAtt RegionalBlocklistIPSetV4.Arn 364 | - - !GetAtt RegionalBlocklistIPSetV4.Arn 365 | - 366 | Effect: "Allow" 367 | Action: 368 | - "ec2:Describe*" 369 | - "ec2:*NetworkAcl*" 370 | Resource: "*" 371 | - 372 | Effect: "Allow" 373 | Action: 374 | - "logs:CreateLogGroup" 375 | - "logs:CreateLogStream" 376 | - "logs:PutLogEvents" 377 | Resource: "arn:aws:logs:*:*:*" 378 | - 379 | Effect: Allow 380 | Action: 381 | - dynamodb:GetItem 382 | - dynamodb:PutItem 383 | - dynamodb:Query 384 | - dynamodb:Scan 385 | - dynamodb:DeleteItem 386 | Resource: !GetAtt GuardDutytoACLDDBTable.Arn 387 | Roles: 388 | - 389 | Ref: "PruneOldEntriesRole" 390 | 391 | PruneOldEntriesSchedule: 392 | Type: "AWS::Events::Rule" 393 | Properties: 394 | Description: "ScheduledPruningRule" 395 | ScheduleExpression: "rate(5 minutes)" 396 | State: "ENABLED" 397 | Targets: 398 | - 399 | Arn: !GetAtt PruneOldEntriesLambda.Arn 400 | Id: "TargetFunctionV1" 401 | 402 | PruneOldEntriesPermissionToInvoke: 403 | DependsOn: 404 | - GuardDutytoACLLambda 405 | Type: "AWS::Lambda::Permission" 406 | Properties: 407 | FunctionName: !Ref PruneOldEntriesLambda 408 | Action: "lambda:InvokeFunction" 409 | Principal: "events.amazonaws.com" 410 | SourceArn: !GetAtt PruneOldEntriesSchedule.Arn 411 | 412 | GuardDutytoACLSNSTopic: 413 | Type: "AWS::SNS::Topic" 414 | Properties: 415 | Subscription: 416 | - 417 | Endpoint: !Ref AdminEmail 418 | Protocol: "email" 419 | 420 | Outputs: 421 | GuardDutytoACLLambda: 422 | Description: GD2ACL Primary Lambda Function. 423 | Value: !Sub https://console.aws.amazon.com/lambda/home?region=${AWS::Region}#/functions/${GuardDutytoACLLambda} 424 | PruneOldEntriesLambda: 425 | Description: GD2ACL Entry Pruning Lambda Function. 426 | Value: !Sub https://console.aws.amazon.com/lambda/home?region=${AWS::Region}#/functions/${PruneOldEntriesLambda} 427 | ACLMetaTable: 428 | Description: GD2ACL DynamoDB State Table 429 | Value: !Ref GuardDutytoACLDDBTable 430 | RegionalIPSetId: 431 | Description: Regional IP Set 432 | Value: !Ref RegionalBlocklistIPSetV4 433 | CloudFrontIPSetId: 434 | Condition: CloudFrontEnable 435 | Description: CloudFront IP Set 436 | Value: !Ref CloudFrontBlocklistIPSetV4 437 | RegionalWebACL: 438 | Description: Regional Web ACL 439 | Value: !Ref RegionalBlocklistWebACL 440 | CloudFrontWebACL: 441 | Condition: CloudFrontEnable 442 | Description: CloudFront Web ACL 443 | Value: !Ref CloudFrontBlocklistWebACL 444 | Retention: 445 | Description: ACL Entry Time to Live in Minutes 446 | Value: !Ref Retention 447 | Region: 448 | Description: Region of the stack. 449 | Value: 450 | Ref: AWS::Region 451 | -------------------------------------------------------------------------------- /lambda/guardduty_to_acl_lambda.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # MIT No Attribution 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | # software and associated documentation files (the "Software"), to deal in the Software 6 | # without restriction, including without limitation the rights to use, copy, modify, 7 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | 17 | import boto3 18 | import math 19 | import time 20 | import json 21 | import logging 22 | import os 23 | import json 24 | from boto3.dynamodb.conditions import Key, Attr 25 | from botocore.exceptions import ClientError 26 | 27 | logger = logging.getLogger() 28 | logger.setLevel(logging.INFO) 29 | 30 | 31 | #====================================================================================================================== 32 | # Variables 33 | #====================================================================================================================== 34 | 35 | API_CALL_NUM_RETRIES = 1 36 | ACLMETATABLE = os.environ['ACLMETATABLE'] 37 | SNSTOPIC = os.environ['SNSTOPIC'] 38 | 39 | REGIONAL_IP_SET = os.environ['REGIONAL_IP_SET'] 40 | RegionalIpSet = REGIONAL_IP_SET.split("|") 41 | 42 | CloudFrontEnable = os.environ['CLOUDFRONT_ENABLE'] 43 | 44 | if CloudFrontEnable == "true": 45 | CLOUDFRONT_IP_SET = os.environ['CLOUDFRONT_IP_SET'] 46 | CloudFrontIpSet = CLOUDFRONT_IP_SET.split("|") 47 | elif CloudFrontEnable == "false": 48 | CloudFrontIpSet = [] 49 | 50 | logger.info("log -- regional ip set: %s -- cloudfront ip set: %s " % (RegionalIpSet, CloudFrontIpSet)) 51 | 52 | 53 | #====================================================================================================================== 54 | # Auxiliary Functions 55 | #====================================================================================================================== 56 | 57 | 58 | def get_ip_set(ip_set_name, ip_set_id, ip_set_scope): 59 | client = boto3.client('wafv2') 60 | response = client.get_ip_set( 61 | Name = ip_set_name, 62 | Scope = ip_set_scope, 63 | Id = ip_set_id 64 | ) 65 | return response 66 | 67 | 68 | def get_ddb_ips(): 69 | ddb = boto3.resource('dynamodb') 70 | table = ddb.Table(ACLMETATABLE) 71 | data = table.scan(FilterExpression=Attr('Region').eq(os.environ['AWS_REGION'])) 72 | response = [] 73 | for i in data['Items']: 74 | response.append(i['HostIp'] + "/32") 75 | logger.debug("log -- hosts in ddb: %s" % (response)) 76 | return response 77 | 78 | 79 | def find_values(id, json_repr): 80 | response = [] 81 | 82 | def _decode_dict(a_dict): 83 | try: 84 | response.append(a_dict[id]) 85 | except KeyError: 86 | pass 87 | return a_dict 88 | 89 | json.loads(json_repr, object_hook=_decode_dict) # Return value ignored. 90 | return response 91 | 92 | 93 | # Update WAF IP set 94 | def waf_update_ip_set(ip_set_name, ip_set_id, ip_set_scope, source_ips): 95 | logger.info('log -- creating waf object') 96 | waf = boto3.client('wafv2') 97 | 98 | for attempt in range(API_CALL_NUM_RETRIES): 99 | try: 100 | response = waf.update_ip_set( 101 | Name = ip_set_name, 102 | Id = ip_set_id, 103 | Scope = ip_set_scope, 104 | LockToken = get_ip_set(ip_set_name, ip_set_id, ip_set_scope)['LockToken'], 105 | Addresses=source_ips 106 | ) 107 | logger.info("log -- waf_update_ip_set %s IP %s - type %s successfully..." % (ip_set_id, source_ips, ip_set_scope)) 108 | except Exception as e: 109 | logger.error(e) 110 | delay = math.pow(2, attempt) 111 | logger.info("log -- waf_update_ip_set retrying in %d seconds..." % (delay)) 112 | time.sleep(delay) 113 | else: 114 | break 115 | else: 116 | logger.error("log -- waf_update_ip_set failed ALL attempts to call WAF API") 117 | 118 | 119 | def waf_update_ip_sets(): 120 | ddb_ips = get_ddb_ips() 121 | if ddb_ips: 122 | logger.info('log -- adding Regional and CloudFront WAF ip entries') 123 | waf_update_ip_set(RegionalIpSet[0], RegionalIpSet[1], RegionalIpSet[2], ddb_ips) 124 | if CloudFrontEnable == "true": 125 | waf_update_ip_set(CloudFrontIpSet[0], CloudFrontIpSet[1], CloudFrontIpSet[2], ddb_ips) 126 | 127 | 128 | # Get the current NACL Id associated with subnet 129 | def get_netacl_id(subnet_id): 130 | 131 | try: 132 | ec2 = boto3.client('ec2') 133 | response = ec2.describe_network_acls( 134 | Filters=[ 135 | { 136 | 'Name': 'association.subnet-id', 137 | 'Values': [ 138 | subnet_id, 139 | ] 140 | } 141 | ] 142 | ) 143 | 144 | netacls = response['NetworkAcls'][0]['Associations'] 145 | 146 | for i in netacls: 147 | if i['SubnetId'] == subnet_id: 148 | netaclid = i['NetworkAclId'] 149 | 150 | return netaclid 151 | except Exception as e: 152 | return [] 153 | 154 | 155 | # Get the current NACL rules in the range 71-80 156 | def get_nacl_rules(netacl_id): 157 | ec2 = boto3.client('ec2') 158 | response = ec2.describe_network_acls( 159 | NetworkAclIds=[ 160 | netacl_id, 161 | ] 162 | ) 163 | 164 | naclrules = [] 165 | 166 | for i in response['NetworkAcls'][0]['Entries']: 167 | naclrules.append(i['RuleNumber']) 168 | 169 | naclrulesf = list(filter(lambda x: 71 <= x <= 80, naclrules)) 170 | 171 | return naclrulesf 172 | 173 | 174 | #Update NACL and DDB state table 175 | def update_nacl(netacl_id, host_ip, region): 176 | logger.info("log -- gd2acl entering update_nacl, netacl_id=%s, host_ip=%s" % (netacl_id, host_ip)) 177 | 178 | ddb = boto3.resource('dynamodb') 179 | table = ddb.Table(ACLMETATABLE) 180 | 181 | hostipexists = table.query( 182 | KeyConditionExpression=Key('NetACLId').eq(netacl_id), 183 | FilterExpression=Attr('HostIp').eq(host_ip) 184 | ) 185 | 186 | # Is HostIp already in table? 187 | if len(hostipexists['Items']) > 0: 188 | logger.info("log -- host IP %s already in table... exiting update." % (host_ip)) 189 | return False 190 | 191 | else: 192 | 193 | # Get current NACL entries in DDB 194 | response = table.query( 195 | KeyConditionExpression=Key('NetACLId').eq(netacl_id) 196 | ) 197 | 198 | # Get all the entries for NACL 199 | naclentries = response['Items'] 200 | 201 | # Find oldest rule and available rule numbers from 71-80 202 | if naclentries: 203 | rulecount = response['Count'] 204 | rulerange = list(range(71, 81)) 205 | 206 | ddbrulerange = [] 207 | naclrulerange = get_nacl_rules(netacl_id) 208 | 209 | for i in naclentries: 210 | ddbrulerange.append(int(i['RuleNo'])) 211 | 212 | # Check state and exit if NACL rule not in sync with DDB 213 | ddbrulerange.sort() 214 | naclrulerange.sort() 215 | synccheck = set(naclrulerange).symmetric_difference(ddbrulerange) 216 | 217 | if ddbrulerange != naclrulerange: 218 | logger.info("log -- current DDB entries, %s." % (ddbrulerange)) 219 | logger.info("log -- current NACL entries, %s." % (naclrulerange)) 220 | logger.error('NACL rule state mismatch, %s exiting' % (sorted(synccheck))) 221 | exit() 222 | 223 | # Determine the NACL rule number and create rule 224 | if rulecount < 10: 225 | # Get the lowest rule number available in the range 226 | newruleno = min([x for x in rulerange if not x in naclrulerange]) 227 | 228 | # Create new NACL rule, IP set entries and DDB state entry 229 | logger.info("log -- adding new rule %s, HostIP %s, to NACL %s." % (newruleno, host_ip, netacl_id)) 230 | create_netacl_rule(netacl_id=netacl_id, host_ip=host_ip, rule_no=newruleno) 231 | create_ddb_rule(netacl_id=netacl_id, host_ip=host_ip, rule_no=newruleno, region=region) 232 | logger.info("log -- all possible NACL rule numbers, %s." % (rulerange)) 233 | logger.info("log -- current DDB entries, %s." % (ddbrulerange)) 234 | logger.info("log -- current NACL entries, %s." % (naclrulerange)) 235 | logger.info("log -- new rule number, %s." % (newruleno)) 236 | logger.info("log -- rule count for NACL %s is %s." % (netacl_id, int(rulecount) + 1)) 237 | return True 238 | 239 | if rulecount >= 10: 240 | # Get oldest entry in DynamoDB table 241 | oldestrule = table.query( 242 | KeyConditionExpression=Key('NetACLId').eq(netacl_id), 243 | ScanIndexForward=True, # true = ascending, false = descending 244 | Limit=1, 245 | ) 246 | 247 | oldruleno = int((oldestrule)['Items'][0]['RuleNo']) 248 | oldrulets = int((oldestrule)['Items'][0]['CreatedAt']) 249 | oldhostip = oldestrule['Items'][0]['HostIp'] 250 | newruleno = oldruleno 251 | 252 | # Delete old NACL rule and DDB state entry 253 | logger.info("log -- deleting current rule %s for IP %s from NACL %s." % (oldruleno, oldhostip, netacl_id)) 254 | delete_netacl_rule(netacl_id=netacl_id, rule_no=oldruleno) 255 | delete_ddb_rule(netacl_id=netacl_id, created_at=oldrulets) 256 | 257 | # check if IP is also recorded in a fresh finding, don't remove IP from blacklist in that case 258 | response_nonexpired = table.scan( FilterExpression=Attr('CreatedAt').gt(oldrulets) & Attr('HostIp').eq(host_ip) ) 259 | 260 | # Create new NACL rule, IP set entries and DDB state entry 261 | logger.info("log -- adding new rule %s, HostIP %s, to NACL %s." % (newruleno, host_ip, netacl_id)) 262 | create_netacl_rule(netacl_id=netacl_id, host_ip=host_ip, rule_no=newruleno) 263 | create_ddb_rule(netacl_id=netacl_id, host_ip=host_ip, rule_no=newruleno, region=region) 264 | logger.info("log -- all possible NACL rule numbers, %s." % (rulerange)) 265 | logger.info("log -- current DDB entries, %s." % (ddbrulerange)) 266 | logger.info("log -- current NACL entries, %s." % (naclrulerange)) 267 | logger.info("log -- rule count for NACL %s is %s." % (netacl_id, int(rulecount))) 268 | return True 269 | 270 | else: 271 | # No entries in DDB Table start from 71 272 | naclrulerange = get_nacl_rules(netacl_id) 273 | newruleno = 71 274 | oldruleno = [] 275 | rulecount = 0 276 | naclrulerange.sort() 277 | 278 | # Error and exit if NACL rules already present 279 | if naclrulerange: 280 | logger.error("log -- NACL %s, has existing entries, %s." % (netacl_id, naclrulerange)) 281 | raise RuntimeError("NACL has existing entries.") 282 | 283 | # Create new NACL rule, IP set entries and DDB state entry 284 | logger.info("log -- adding new rule %s, HostIP %s, to NACL %s." % (newruleno, host_ip, netacl_id)) 285 | create_netacl_rule(netacl_id=netacl_id, host_ip=host_ip, rule_no=newruleno) 286 | create_ddb_rule(netacl_id=netacl_id, host_ip=host_ip, rule_no=newruleno, region=region) 287 | logger.info("log -- rule count for NACL %s is %s." % (netacl_id, int(rulecount) + 1)) 288 | return True 289 | 290 | if response['ResponseMetadata']['HTTPStatusCode'] == 200: 291 | return True 292 | else: 293 | return False 294 | 295 | 296 | # Create NACL rule 297 | def create_netacl_rule(netacl_id, host_ip, rule_no): 298 | 299 | ec2 = boto3.resource('ec2') 300 | network_acl = ec2.NetworkAcl(netacl_id) 301 | 302 | response = network_acl.create_entry( 303 | CidrBlock = host_ip + '/32', 304 | Egress=False, 305 | PortRange={ 306 | 'From': 0, 307 | 'To': 65535 308 | }, 309 | Protocol='-1', 310 | RuleAction='deny', 311 | RuleNumber= rule_no 312 | ) 313 | 314 | if response['ResponseMetadata']['HTTPStatusCode'] == 200: 315 | logger.info("log -- successfully added new rule %s, HostIP %s, to NACL %s." % (rule_no, host_ip, netacl_id)) 316 | return True 317 | else: 318 | logger.error("log -- error adding new rule %s, HostIP %s, to NACL %s." % (rule_no, host_ip, netacl_id)) 319 | logger.info(response) 320 | return False 321 | 322 | 323 | # Delete NACL rule 324 | def delete_netacl_rule(netacl_id, rule_no): 325 | 326 | ec2 = boto3.resource('ec2') 327 | network_acl = ec2.NetworkAcl(netacl_id) 328 | 329 | response = network_acl.delete_entry( 330 | Egress=False, 331 | RuleNumber=rule_no 332 | ) 333 | 334 | if response['ResponseMetadata']['HTTPStatusCode'] == 200: 335 | logger.info("log -- successfully deleted rule %s, from NACL %s." % (rule_no, netacl_id)) 336 | return True 337 | else: 338 | logger.info("log -- error deleting rule %s, from NACL %s." % (rule_no, netacl_id)) 339 | logger.info(response) 340 | return False 341 | 342 | 343 | # Create DDB state entry for NACL rule 344 | def create_ddb_rule(netacl_id, host_ip, rule_no, region): 345 | 346 | ddb = boto3.resource('dynamodb') 347 | table = ddb.Table(ACLMETATABLE) 348 | timestamp = int(time.time()) 349 | 350 | response = table.put_item( 351 | Item={ 352 | 'NetACLId': netacl_id, 353 | 'CreatedAt': timestamp, 354 | 'HostIp': str(host_ip), 355 | 'RuleNo': str(rule_no), 356 | 'Region': str(region) 357 | } 358 | ) 359 | 360 | if response['ResponseMetadata']['HTTPStatusCode'] == 200: 361 | logger.info("log -- successfully added DDB state entry for rule %s, HostIP %s, NACL %s." % (rule_no, host_ip, netacl_id)) 362 | return True 363 | else: 364 | logger.error("log -- error adding DDB state entry for rule %s, HostIP %s, NACL %s." % (rule_no, host_ip, netacl_id)) 365 | logger.info(response) 366 | return False 367 | 368 | 369 | # Delete DDB state entry for NACL rule 370 | def delete_ddb_rule(netacl_id, created_at): 371 | 372 | ddb = boto3.resource('dynamodb') 373 | table = ddb.Table(ACLMETATABLE) 374 | 375 | response = table.delete_item( 376 | Key={ 377 | 'NetACLId': netacl_id, 378 | 'CreatedAt': int(created_at) 379 | } 380 | ) 381 | 382 | if response['ResponseMetadata']['HTTPStatusCode'] == 200: 383 | logger.info("log -- successfully deleted DDB state entry for NACL %s." % (netacl_id)) 384 | return True 385 | else: 386 | logger.error("log -- error deleting DDB state entry for NACL %s." % (netacl_id)) 387 | logger.info(response) 388 | return False 389 | 390 | 391 | # Send notification to SNS topic 392 | def admin_notify(iphost, findingtype, naclid, region, instanceid, findingid): 393 | 394 | MESSAGE = ("GuardDuty to ACL Event Info:\r\n" 395 | "Suspicious activity detected from host " + iphost + " due to " + findingtype + 396 | "against EC2 Instance: " + instanceid + ". The following ACL resources were targeted for update as needed: " + '\n' 397 | "CloudFront IP Set: " + CLOUDFRONT_IP_SET + '\n' 398 | "Regional IP Set: " + REGIONAL_IP_SET + '\n' 399 | "VPC NACL: " + naclid + '\n' 400 | "Region: " + region + '\n' 401 | "Finding Link: " + "https://console.aws.amazon.com/guardduty/home?region=" + region + "#/findings?macros=current&search=id%3D" + findingid 402 | ) 403 | 404 | sns = boto3.client(service_name="sns") 405 | 406 | # Try to send the notification. 407 | try: 408 | 409 | sns.publish( 410 | TopicArn = SNSTOPIC, 411 | Message = MESSAGE, 412 | Subject='AWS GD2ACL Alert' 413 | ) 414 | logger.info("log -- send notification sent to SNS Topic: %s" % (SNSTOPIC)) 415 | 416 | # Display an error if something goes wrong. 417 | except ClientError as e: 418 | logger.error('log -- error sending notification.') 419 | raise 420 | 421 | 422 | #====================================================================================================================== 423 | # Lambda Entry Point 424 | #====================================================================================================================== 425 | 426 | 427 | # Lambda handler 428 | def lambda_handler(event, context): 429 | 430 | logger.info("log -- Event: %s " % json.dumps(event)) 431 | 432 | try: 433 | if 'Recon:EC2/PortProbe' in event["detail"]["type"]: 434 | HostIp = [] 435 | FindingID = event["detail"]["id"] 436 | remoteIpDetail = find_values('remoteIpDetails', json.dumps(event)) 437 | Region = event["region"] 438 | SubnetId = event["detail"]["resource"]["instanceDetails"]["networkInterfaces"][0]["subnetId"] 439 | for i in event["detail"]["service"]["action"]["portProbeAction"]["portProbeDetails"]: 440 | HostIp.append(str(i["remoteIpDetails"]["ipAddressV4"])) 441 | instanceID = event["detail"]["resource"]["instanceDetails"]["instanceId"] 442 | NetworkAclId = get_netacl_id(subnet_id=SubnetId) 443 | else: 444 | HostIp = [] 445 | FindingID = event["detail"]["id"] 446 | Region = event["region"] 447 | instanceID = find_values('instanceId', json.dumps(event)) 448 | SubnetId = find_values('subnetId', json.dumps(event)) 449 | remoteIpDetail = find_values('remoteIpDetails', json.dumps(event)) 450 | if not remoteIpDetail or not SubnetId: 451 | pass 452 | else: 453 | HostIp.append((remoteIpDetail)[0]["ipAddressV4"]) 454 | NetworkAclId = get_netacl_id(subnet_id=SubnetId[0]) 455 | 456 | 457 | if len(HostIp) > 0 and NetworkAclId: 458 | logger.info("log -- gd2acl attempting to process finding data: instanceID: %s - SubnetId: %s - RemoteHostIp: %s" % (instanceID[0], SubnetId[0], HostIp)) 459 | update_counter = 0 460 | 461 | # Update VPC NACL 462 | for ip in HostIp: 463 | response = update_nacl(netacl_id=NetworkAclId, host_ip=ip, region=Region) 464 | if response is True: 465 | update_counter = update_counter + 1 466 | 467 | # Update WAF IP Sets 468 | if update_counter > 0: 469 | logger.info('log -- adding Regional and CloudFront WAF IP set entry for host, %s from CloudFront Ip set %s and REGION IP set %s.' % (HostIp, CLOUDFRONT_IP_SET, REGIONAL_IP_SET)) 470 | waf_update_ip_sets() 471 | 472 | #Send Notification 473 | admin_notify(str(HostIp), event["detail"]["type"], NetworkAclId, Region, str(instanceID), str(FindingID)) 474 | 475 | logger.info("log -- processing GuardDuty finding completed successfully") 476 | 477 | else: 478 | logger.warning("log -- unable to determine required info from finding - instanceID: %s, SubnetId: %s, RemoteIp: %s" % (instanceID, SubnetId, HostIp)) 479 | pass 480 | 481 | except Exception as e: 482 | logger.error('log -- something went wrong.') 483 | raise 484 | --------------------------------------------------------------------------------