├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cfnresponse.py ├── ct_configrecorder_override_consumer.py ├── ct_configrecorder_override_producer.py └── template.yaml /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 | -------------------------------------------------------------------------------- /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, or recently closed, 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 *main* 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' 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](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 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 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to 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 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Customize AWS Config resource tracking in AWS Control Tower environment 2 | This github repository is part of AWS blog post https://aws.amazon.com/blogs/mt/customize-aws-config-resource-tracking-in-aws-control-tower-environment/ 3 | 4 | Please refer to the blog for what this sample code does and how to use it. 5 | 6 | ## Security 7 | 8 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 9 | 10 | ## License 11 | 12 | This library is licensed under the MIT-0 License. See the LICENSE file. 13 | 14 | -------------------------------------------------------------------------------- /cfnresponse.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | from __future__ import print_function 5 | import urllib3 6 | import json 7 | 8 | SUCCESS = "SUCCESS" 9 | FAILED = "FAILED" 10 | 11 | http = urllib3.PoolManager() 12 | 13 | 14 | def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None): 15 | responseUrl = event['ResponseURL'] 16 | 17 | print(responseUrl) 18 | 19 | responseBody = { 20 | 'Status' : responseStatus, 21 | 'Reason' : reason or "See the details in CloudWatch Log Stream: {}".format(context.log_stream_name), 22 | 'PhysicalResourceId' : physicalResourceId or context.log_stream_name, 23 | 'StackId' : event['StackId'], 24 | 'RequestId' : event['RequestId'], 25 | 'LogicalResourceId' : event['LogicalResourceId'], 26 | 'NoEcho' : noEcho, 27 | 'Data' : responseData 28 | } 29 | 30 | json_responseBody = json.dumps(responseBody) 31 | 32 | print("Response body:") 33 | print(json_responseBody) 34 | 35 | headers = { 36 | 'content-type' : '', 37 | 'content-length' : str(len(json_responseBody)) 38 | } 39 | 40 | try: 41 | response = http.request('PUT', responseUrl, headers=headers, body=json_responseBody) 42 | print("Status code:", response.status) 43 | 44 | 45 | except Exception as e: 46 | 47 | print("send(..) failed executing http.request(..):", e) -------------------------------------------------------------------------------- /ct_configrecorder_override_consumer.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the "Software"), 7 | # to deal in the Software without restriction, including without limitation 8 | # the rights to use, copy, modify,merge, publish, distribute, sublicense, 9 | # and/or sell copies of the Software, and to permit persons to whom the 10 | # Software is furnished to do so. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 13 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 18 | # IN THE SOFTWARE. 19 | # 20 | 21 | import boto3 22 | import json 23 | import logging 24 | import botocore.exceptions 25 | import os 26 | 27 | 28 | def lambda_handler(event, context): 29 | LOG_LEVEL = os.getenv('LOG_LEVEL') 30 | logging.getLogger().setLevel(LOG_LEVEL) 31 | 32 | try: 33 | 34 | logging.info(f'Event: {event}') 35 | 36 | body = json.loads(event['Records'][0]['body']) 37 | account_id = body['Account'] 38 | aws_region = body['Region'] 39 | event = body['Event'] 40 | 41 | logging.info(f'Extracted Account: {account_id}') 42 | logging.info(f'Extracted Region: {aws_region}') 43 | logging.info(f'Extracted Event: {event}') 44 | 45 | bc = botocore.__version__ 46 | b3 = boto3.__version__ 47 | 48 | logging.info(f'Botocore : {bc}') 49 | logging.info(f'Boto3 : {b3}') 50 | 51 | STS = boto3.client("sts") 52 | 53 | def assume_role(account_id, role='AWSControlTowerExecution'): 54 | ''' 55 | Return a session in the target account using Control Tower Role 56 | ''' 57 | try: 58 | curr_account = STS.get_caller_identity()['Account'] 59 | if curr_account != account_id: 60 | part = STS.get_caller_identity()['Arn'].split(":")[1] 61 | 62 | role_arn = 'arn:' + part + ':iam::' + account_id + ':role/' + role 63 | ses_name = str(account_id + '-' + role) 64 | response = STS.assume_role(RoleArn=role_arn, RoleSessionName=ses_name) 65 | sts_session = boto3.Session( 66 | aws_access_key_id=response['Credentials']['AccessKeyId'], 67 | aws_secret_access_key=response['Credentials']['SecretAccessKey'], 68 | aws_session_token=response['Credentials']['SessionToken']) 69 | 70 | return sts_session 71 | except botocore.exceptions.ClientError as exe: 72 | logging.error('Unable to assume role') 73 | raise exe 74 | 75 | sts_session = assume_role(account_id) 76 | logging.info(f'Printing STS session: {sts_session}') 77 | 78 | # Use the session and create a client for configservice 79 | configservice = sts_session.client('config', region_name=aws_region) 80 | 81 | # Describe configuration recorder 82 | configrecorder = configservice.describe_configuration_recorders() 83 | logging.info(f'Existing Configuration Recorder: {configrecorder}') 84 | 85 | # Get the name of the existing recorder if it exists, otherwise use the default name 86 | recorder_name = 'aws-controltower-BaselineConfigRecorder' 87 | if configrecorder and 'ConfigurationRecorders' in configrecorder and len(configrecorder['ConfigurationRecorders']) > 0: 88 | recorder_name = configrecorder['ConfigurationRecorders'][0]['name'] 89 | logging.info(f'Using existing recorder name: {recorder_name}') 90 | 91 | # ControlTower created configuration recorder with name "aws-controltower-BaselineConfigRecorder" and we will update just that 92 | try: 93 | role_arn = 'arn:aws:iam::' + account_id + ':role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig' 94 | 95 | CONFIG_RECORDER_DAILY_RESOURCE_STRING = os.getenv('CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST') 96 | CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST = CONFIG_RECORDER_DAILY_RESOURCE_STRING.split( 97 | ',') if CONFIG_RECORDER_DAILY_RESOURCE_STRING != '' else [] 98 | 99 | CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_STRING = os.getenv('CONFIG_RECORDER_OVERRIDE_DAILY_GLOBAL_RESOURCE_LIST') 100 | CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_LIST = CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_STRING.split( 101 | ',') if CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_STRING != '' else [] 102 | 103 | 104 | CONFIG_RECORDER_EXCLUSION_RESOURCE_STRING = os.getenv('CONFIG_RECORDER_OVERRIDE_EXCLUDED_RESOURCE_LIST') 105 | CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST = CONFIG_RECORDER_EXCLUSION_RESOURCE_STRING.split( 106 | ',') if CONFIG_RECORDER_EXCLUSION_RESOURCE_STRING != '' else [] 107 | CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY = os.getenv('CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY') 108 | 109 | #remove any resource type from daily list that are in exclision list 110 | res = [x for x in CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST if x not in CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST] 111 | CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST[:] = res 112 | 113 | # Event = Delete is when stack is deleted, we rollback changed made and leave it as ControlTower Intended 114 | home_region = os.getenv('CONTROL_TOWER_HOME_REGION') == aws_region 115 | if home_region: 116 | CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST += CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_LIST 117 | 118 | if event == 'Delete': 119 | response = configservice.put_configuration_recorder( 120 | ConfigurationRecorder={ 121 | 'name': recorder_name, 122 | 'roleARN': role_arn, 123 | 'recordingGroup': { 124 | 'allSupported': True, 125 | 'includeGlobalResourceTypes': home_region 126 | } 127 | }) 128 | logging.info(f'Response for put_configuration_recorder :{response} ') 129 | 130 | else: 131 | config_recorder = { 132 | 'name': recorder_name, 133 | 'roleARN': role_arn, 134 | 'recordingGroup': { 135 | 'allSupported': False, 136 | 'includeGlobalResourceTypes': False, 137 | 'exclusionByResourceTypes': { 138 | 'resourceTypes': CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST 139 | }, 140 | 'recordingStrategy': { 141 | 'useOnly': 'EXCLUSION_BY_RESOURCE_TYPES' 142 | } 143 | }, 144 | 'recordingMode': { 145 | 'recordingFrequency': CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY, 146 | 'recordingModeOverrides': [ 147 | { 148 | 'description': 'DAILY_OVERRIDE', 149 | 'resourceTypes': CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST, 150 | 'recordingFrequency': 'DAILY' 151 | } 152 | ] if CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST else [] 153 | } 154 | } 155 | 156 | if not CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST: 157 | config_recorder['recordingGroup'].pop('exclusionByResourceTypes') 158 | config_recorder['recordingGroup'].pop('recordingStrategy') 159 | config_recorder['recordingGroup']['allSupported'] = True 160 | config_recorder['recordingGroup']['includeGlobalResourceTypes'] = True 161 | response = configservice.put_configuration_recorder( 162 | ConfigurationRecorder=config_recorder) 163 | logging.info(f'Response for put_configuration_recorder :{response} ') 164 | 165 | # lets describe for configuration recorder after the update 166 | configrecorder = configservice.describe_configuration_recorders() 167 | logging.info(f'Post Change Configuration recorder : {configrecorder}') 168 | 169 | except botocore.exceptions.ClientError as exe: 170 | logging.error('Unable to Update Config Recorder for Account and Region : ', account_id, aws_region) 171 | configrecorder = configservice.describe_configuration_recorders() 172 | logging.info(f'Exception : {configrecorder}') 173 | raise exe 174 | 175 | return { 176 | 'statusCode': 200 177 | } 178 | 179 | except Exception as e: 180 | exception_type = e.__class__.__name__ 181 | exception_message = str(e) 182 | logging.exception(f'{exception_type}: {exception_message}') 183 | -------------------------------------------------------------------------------- /ct_configrecorder_override_producer.py: -------------------------------------------------------------------------------- 1 | 2 | # 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: MIT-0 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining 7 | # a copy of this software and associated documentation files (the "Software"), 8 | # to deal in the Software without restriction, including without limitation 9 | # the rights to use, copy, modify,merge, publish, distribute, sublicense, 10 | # and/or sell copies of the Software, and to permit persons to whom the 11 | # Software is furnished to do so. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | # IN THE SOFTWARE. 20 | # 21 | 22 | import boto3 23 | import cfnresponse 24 | import os 25 | import logging 26 | import ast 27 | 28 | def lambda_handler(event, context): 29 | 30 | LOG_LEVEL = os.getenv('LOG_LEVEL') 31 | logging.getLogger().setLevel(LOG_LEVEL) 32 | 33 | try: 34 | logging.info('Event Data: ') 35 | logging.info(event) 36 | sqs_url = os.getenv('SQS_URL') 37 | excluded_accounts = os.getenv('EXCLUDED_ACCOUNTS') 38 | logging.info(f'Excluded Accounts: {excluded_accounts}') 39 | sqs_client = boto3.client('sqs') 40 | 41 | # Check if the lambda was trigerred from EventBridge. 42 | # If so extract Account and Event info from the event data. 43 | 44 | is_eb_trigerred = 'source' in event 45 | 46 | logging.info(f'Is EventBridge Trigerred: {str(is_eb_trigerred)}') 47 | event_source = '' 48 | 49 | if is_eb_trigerred: 50 | event_source = event['source'] 51 | logging.info(f'Control Tower Event Source: {event_source}') 52 | event_name = event['detail']['eventName'] 53 | logging.info(f'Control Tower Event Name: {event_name}') 54 | 55 | if event_source == 'aws.controltower' and event_name == 'UpdateManagedAccount': 56 | account = event['detail']['serviceEventDetails']['updateManagedAccountStatus']['account']['accountId'] 57 | logging.info(f'overriding config recorder for SINGLE account: {account}') 58 | override_config_recorder(excluded_accounts, sqs_url, account, 'controltower') 59 | elif event_source == 'aws.controltower' and event_name == 'CreateManagedAccount': 60 | account = event['detail']['serviceEventDetails']['createManagedAccountStatus']['account']['accountId'] 61 | logging.info(f'overriding config recorder for SINGLE account: {account}') 62 | override_config_recorder(excluded_accounts, sqs_url, account, 'controltower') 63 | elif event_source == 'aws.controltower' and event_name == 'UpdateLandingZone': 64 | logging.info('overriding config recorder for ALL accounts due to UpdateLandingZone event') 65 | override_config_recorder(excluded_accounts, sqs_url, '', 'controltower') 66 | elif ('LogicalResourceId' in event) and (event['RequestType'] == 'Create'): 67 | logging.info('CREATE CREATE') 68 | logging.info( 69 | 'overriding config recorder for ALL accounts because of first run after function deployment from CloudFormation') 70 | override_config_recorder(excluded_accounts, sqs_url, '', 'Create') 71 | response = {} 72 | ## Send signal back to CloudFormation after the first run 73 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response, "CustomResourcePhysicalID") 74 | elif ('LogicalResourceId' in event) and (event['RequestType'] == 'Update'): 75 | logging.info('Update Update') 76 | logging.info( 77 | 'overriding config recorder for ALL accounts because of first run after function deployment from CloudFormation') 78 | override_config_recorder(excluded_accounts, sqs_url, '', 'Update') 79 | response = {} 80 | update_excluded_accounts(excluded_accounts,sqs_url) 81 | 82 | ## Send signal back to CloudFormation after the first run 83 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response, "CustomResourcePhysicalID") 84 | elif ('LogicalResourceId' in event) and (event['RequestType'] == 'Delete'): 85 | logging.info('DELETE DELETE') 86 | logging.info( 87 | 'overriding config recorder for ALL accounts because of first run after function deployment from CloudFormation') 88 | override_config_recorder(excluded_accounts, sqs_url, '', 'Delete') 89 | response = {} 90 | ## Send signal back to CloudFormation after the final run 91 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response, "CustomResourcePhysicalID") 92 | else: 93 | logging.info("No matching event found") 94 | 95 | logging.info('Execution Successful') 96 | 97 | # TODO implement 98 | return { 99 | 'statusCode': 200 100 | } 101 | 102 | except Exception as e: 103 | exception_type = e.__class__.__name__ 104 | exception_message = str(e) 105 | logging.exception(f'{exception_type}: {exception_message}') 106 | 107 | 108 | def override_config_recorder(excluded_accounts, sqs_url, account, event): 109 | 110 | try: 111 | client = boto3.client('cloudformation') 112 | # Create a reusable Paginator 113 | paginator = client.get_paginator('list_stack_instances') 114 | 115 | # Create a PageIterator from the Paginator 116 | if account == '': 117 | page_iterator = paginator.paginate(StackSetName ='AWSControlTowerBP-BASELINE-CONFIG') 118 | else: 119 | page_iterator = paginator.paginate(StackSetName ='AWSControlTowerBP-BASELINE-CONFIG', StackInstanceAccount=account) 120 | 121 | sqs_client = boto3.client('sqs') 122 | for page in page_iterator: 123 | logging.info(page) 124 | 125 | for item in page['Summaries']: 126 | account = item['Account'] 127 | region = item['Region'] 128 | send_message_to_sqs(event, account, region, excluded_accounts, sqs_client, sqs_url) 129 | 130 | except Exception as e: 131 | exception_type = e.__class__.__name__ 132 | exception_message = str(e) 133 | logging.exception(f'{exception_type}: {exception_message}') 134 | 135 | def send_message_to_sqs(event, account, region, excluded_accounts, sqs_client, sqs_url): 136 | 137 | try: 138 | 139 | #Proceed only if the account is not excluded 140 | if account not in excluded_accounts: 141 | 142 | #construct sqs message 143 | sqs_msg = f'{{"Account": "{account}", "Region": "{region}", "Event": "{event}"}}' 144 | 145 | #send message to sqs 146 | response = sqs_client.send_message( 147 | QueueUrl=sqs_url, 148 | MessageBody=sqs_msg) 149 | logging.info(f'message sent to sqs: {sqs_msg}') 150 | 151 | else: 152 | logging.info(f'Account excluded: {account}') 153 | 154 | except Exception as e: 155 | exception_type = e.__class__.__name__ 156 | exception_message = str(e) 157 | logging.exception(f'{exception_type}: {exception_message}') 158 | 159 | def update_excluded_accounts(excluded_accounts,sqs_url): 160 | 161 | try: 162 | acctid = boto3.client('sts') 163 | 164 | new_excluded_accounts = "['" + acctid.get_caller_identity().get('Account') + "']" 165 | 166 | logging.info(f'templist: {new_excluded_accounts}') 167 | 168 | templist=ast.literal_eval(excluded_accounts) 169 | 170 | templist_out=[] 171 | 172 | for acct in templist: 173 | 174 | if acctid.get_caller_identity().get('Account') != acct: 175 | templist_out.append(acct) 176 | logging.info(f'Delete request sent: {acct}') 177 | override_config_recorder(new_excluded_accounts, sqs_url, acct, 'Delete') 178 | 179 | except Exception as e: 180 | exception_type = e.__class__.__name__ 181 | exception_message = str(e) 182 | logging.exception(f'{exception_type}: {exception_message}') -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: 'AWS CloudFormation Template to update config recorder settings in child accounts created by ControlTower.' 3 | Parameters: 4 | 5 | ExcludedAccounts: 6 | Description: Excluded Accounts list. This list should contain Management account, Log Archive and Audit accounts at the minimum 7 | Default: "['111111111111', '222222222222', '333333333333']" 8 | MaxLength: '2000' 9 | MinLength: '36' 10 | Type: String 11 | 12 | ConfigRecorderExcludedResourceTypes: 13 | Description: List of all resource types to be excluded from Config Recorder 14 | Default: "AWS::HealthLake::FHIRDatastore,AWS::Pinpoint::Segment,AWS::Pinpoint::ApplicationSettings" 15 | Type: String 16 | 17 | ConfigRecorderDailyResourceTypes: 18 | Description: List of all resource types to be set to a daily cadence 19 | Default: "AWS::AutoScaling::AutoScalingGroup,AWS::AutoScaling::LaunchConfiguration" 20 | Type: String 21 | 22 | ConfigRecorderDailyGlobalResourceTypes: 23 | Description: List of Global resource types to be set to a daily cadence in the AWS Control Tower home region 24 | Default: "AWS::IAM::Policy,AWS::IAM::User,AWS::IAM::Role,AWS::IAM::Group" 25 | Type: String 26 | 27 | ConfigRecorderDefaultRecordingFrequency: 28 | Description: Default Frequency of recording configuration changes. 29 | Default: CONTINUOUS 30 | Type: String 31 | AllowedValues: 32 | - CONTINUOUS 33 | - DAILY 34 | 35 | CloudFormationVersion: 36 | Type: String 37 | Default: 1 38 | 39 | 40 | Resources: 41 | LambdaZipsBucket: 42 | Type: AWS::S3::Bucket 43 | Properties: 44 | BucketEncryption: 45 | ServerSideEncryptionConfiguration: 46 | - ServerSideEncryptionByDefault: 47 | SSEAlgorithm: AES256 48 | 49 | LambdaZipsBucketPolicy: 50 | Type: AWS::S3::BucketPolicy 51 | Properties: 52 | Bucket: 53 | Ref: LambdaZipsBucket 54 | PolicyDocument: 55 | Statement: 56 | - Effect: Deny 57 | Action: "s3:*" 58 | Principal: "*" 59 | Resource: 60 | - !Sub 'arn:aws:s3:::${LambdaZipsBucket}' 61 | - !Sub 'arn:aws:s3:::${LambdaZipsBucket}/*' 62 | Condition: 63 | Bool: 64 | aws:SecureTransport: false 65 | 66 | ProducerLambda: 67 | Type: AWS::Lambda::Function 68 | DeletionPolicy: Retain 69 | DependsOn: CopyZips 70 | Properties: 71 | Code: 72 | S3Bucket: !Ref LambdaZipsBucket 73 | S3Key: ct-blogs-content/ct_configrecorder_override_producer.zip 74 | Handler: ct_configrecorder_override_producer.lambda_handler 75 | Role: !GetAtt ProducerLambdaExecutionRole.Arn 76 | Runtime: python3.12 77 | MemorySize: 128 78 | Timeout: 300 79 | Architectures: 80 | - x86_64 81 | ReservedConcurrentExecutions: 1 82 | Environment: 83 | Variables: 84 | EXCLUDED_ACCOUNTS: !Ref ExcludedAccounts 85 | LOG_LEVEL: INFO 86 | SQS_URL: !Ref SQSConfigRecorder 87 | 88 | ProducerLambdaPermissions: 89 | Type: AWS::Lambda::Permission 90 | DeletionPolicy: Retain 91 | Properties: 92 | Action: 'lambda:InvokeFunction' 93 | FunctionName: !Ref ProducerLambda 94 | Principal: 'events.amazonaws.com' 95 | SourceArn: !GetAtt ProducerEventTrigger.Arn 96 | 97 | ConsumerLambda: 98 | Type: AWS::Lambda::Function 99 | DeletionPolicy: Retain 100 | DependsOn: CopyZips 101 | Properties: 102 | Code: 103 | S3Bucket: !Ref LambdaZipsBucket 104 | S3Key: ct-blogs-content/ct_configrecorder_override_consumer.zip 105 | Handler: ct_configrecorder_override_consumer.lambda_handler 106 | Role: !GetAtt ConsumerLambdaExecutionRole.Arn 107 | Runtime: python3.12 108 | MemorySize: 128 109 | Timeout: 180 110 | Architectures: 111 | - x86_64 112 | ReservedConcurrentExecutions: 10 113 | Environment: 114 | Variables: 115 | LOG_LEVEL: INFO 116 | CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST: !Ref ConfigRecorderDailyResourceTypes 117 | CONFIG_RECORDER_OVERRIDE_DAILY_GLOBAL_RESOURCE_LIST: !Ref ConfigRecorderDailyGlobalResourceTypes 118 | CONFIG_RECORDER_OVERRIDE_EXCLUDED_RESOURCE_LIST: !Ref ConfigRecorderExcludedResourceTypes 119 | CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY: !Ref ConfigRecorderDefaultRecordingFrequency 120 | CONTROL_TOWER_HOME_REGION: !Ref 'AWS::Region' 121 | 122 | ConsumerLambdaEventSourceMapping: 123 | Type: AWS::Lambda::EventSourceMapping 124 | DeletionPolicy: Retain 125 | Properties: 126 | BatchSize: 1 127 | Enabled: true 128 | EventSourceArn: !GetAtt SQSConfigRecorder.Arn 129 | FunctionName: !GetAtt ConsumerLambda.Arn 130 | 131 | ProducerLambdaExecutionRole: 132 | Type: 'AWS::IAM::Role' 133 | DeletionPolicy: Retain 134 | Properties: 135 | ManagedPolicyArns: 136 | - !Sub 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' 137 | AssumeRolePolicyDocument: 138 | Version: '2012-10-17' 139 | Statement: 140 | - Effect: Allow 141 | Principal: 142 | Service: 143 | - lambda.amazonaws.com 144 | Action: 145 | - 'sts:AssumeRole' 146 | Path: / 147 | Policies: 148 | - PolicyName: ct_cro_producer 149 | PolicyDocument: 150 | Version: '2012-10-17' 151 | Statement: 152 | - Effect: Allow 153 | Action: 154 | - cloudformation:ListStackInstances 155 | Resource: !Sub 'arn:${AWS::Partition}:cloudformation:*:*:stackset/AWSControlTowerBP-BASELINE-CONFIG:*' 156 | - Effect: Allow 157 | Action: 158 | - sqs:DeleteMessage 159 | - sqs:ReceiveMessage 160 | - sqs:SendMessage 161 | - sqs:GetQueueAttributes 162 | Resource: !GetAtt SQSConfigRecorder.Arn 163 | 164 | ConsumerLambdaExecutionRole: 165 | Type: 'AWS::IAM::Role' 166 | DeletionPolicy: Retain 167 | Properties: 168 | ManagedPolicyArns: 169 | - !Sub 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' 170 | AssumeRolePolicyDocument: 171 | Version: '2012-10-17' 172 | Statement: 173 | - Effect: Allow 174 | Principal: 175 | Service: 176 | - lambda.amazonaws.com 177 | Action: 178 | - 'sts:AssumeRole' 179 | Path: / 180 | Policies: 181 | - PolicyName: policy-sts-all 182 | PolicyDocument: 183 | Version: '2012-10-17' 184 | Statement: 185 | - Effect: Allow 186 | Action: 187 | - sts:AssumeRole 188 | Resource: "*" 189 | - Effect: Allow 190 | Action: 191 | - sqs:DeleteMessage 192 | - sqs:ReceiveMessage 193 | - sqs:SendMessage 194 | - sqs:GetQueueAttributes 195 | Resource: !GetAtt SQSConfigRecorder.Arn 196 | 197 | SQSConfigRecorder: 198 | Type: AWS::SQS::Queue 199 | DeletionPolicy: Retain 200 | Properties: 201 | VisibilityTimeout: 180 202 | DelaySeconds: 5 203 | KmsMasterKeyId: alias/aws/sqs 204 | 205 | ProducerEventTrigger: 206 | Type: AWS::Events::Rule 207 | Properties: 208 | Description: "Rule to trigger config recorder override producer lambda" 209 | EventBusName: default 210 | EventPattern: '{ 211 | "source": ["aws.controltower"], 212 | "detail-type": ["AWS Service Event via CloudTrail"], 213 | "detail": { 214 | "eventName": ["UpdateLandingZone", "CreateManagedAccount", "UpdateManagedAccount"] 215 | } 216 | }' 217 | Name: !GetAtt SQSConfigRecorder.QueueName 218 | State: ENABLED 219 | Targets: 220 | - 221 | Arn: 222 | Fn::GetAtt: 223 | - "ProducerLambda" 224 | - "Arn" 225 | Id: "ProducerTarget" 226 | 227 | ProducerLambdaTrigger: 228 | Type: 'Custom::ExecuteLambda' 229 | Properties: 230 | ServiceToken: !GetAtt "ProducerLambda.Arn" 231 | FunctionName: !Ref ProducerLambda 232 | Version: !Ref CloudFormationVersion 233 | 234 | CopyZips: 235 | Type: Custom::CopyZips 236 | Properties: 237 | ServiceToken: !GetAtt 'CopyZipsFunction.Arn' 238 | DestBucket: !Ref 'LambdaZipsBucket' 239 | SourceBucket: marketplace-sa-resources 240 | Prefix: ct-blogs-content/ 241 | Objects: 242 | - 'ct_configrecorder_override_producer.zip' 243 | - 'ct_configrecorder_override_consumer.zip' 244 | 245 | CopyZipsRole: 246 | Type: AWS::IAM::Role 247 | Properties: 248 | AssumeRolePolicyDocument: 249 | Version: '2012-10-17' 250 | Statement: 251 | - Effect: Allow 252 | Principal: 253 | Service: lambda.amazonaws.com 254 | Action: sts:AssumeRole 255 | ManagedPolicyArns: 256 | - !Sub 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' 257 | Path: / 258 | Policies: 259 | - PolicyName: lambda-copier 260 | PolicyDocument: 261 | Version: '2012-10-17' 262 | Statement: 263 | - Effect: Allow 264 | Action: 265 | - s3:GetObject 266 | - s3:GetObjectTagging 267 | Resource: !Sub 'arn:${AWS::Partition}:s3:::marketplace-sa-resources/ct-blogs-content/*' 268 | - Effect: Allow 269 | Action: 270 | - s3:PutObject 271 | - s3:DeleteObject 272 | - s3:PutObjectTagging 273 | Resource: 274 | - !Sub 'arn:${AWS::Partition}:s3:::${LambdaZipsBucket}/ct-blogs-content/*' 275 | 276 | CopyZipsFunction: 277 | Type: AWS::Lambda::Function 278 | Properties: 279 | Description: Copies objects from the S3 bucket to a new location. 280 | Handler: index.handler 281 | Runtime: python3.12 282 | Role: !GetAtt 'CopyZipsRole.Arn' 283 | ReservedConcurrentExecutions: 1 284 | Timeout: 300 285 | Code: 286 | ZipFile: | 287 | import json 288 | import logging 289 | import threading 290 | import boto3 291 | import cfnresponse 292 | def copy_objects(source_bucket, dest_bucket, prefix, objects): 293 | s3 = boto3.client('s3') 294 | for o in objects: 295 | key = prefix + o 296 | copy_source = { 297 | 'Bucket': source_bucket, 298 | 'Key': key 299 | } 300 | print('copy_source: %s' % copy_source) 301 | print('dest_bucket = %s'%dest_bucket) 302 | print('key = %s' %key) 303 | s3.copy_object(CopySource=copy_source, Bucket=dest_bucket, 304 | Key=key) 305 | def delete_objects(bucket, prefix, objects): 306 | s3 = boto3.client('s3') 307 | objects = {'Objects': [{'Key': prefix + o} for o in objects]} 308 | s3.delete_objects(Bucket=bucket, Delete=objects) 309 | def timeout(event, context): 310 | logging.error('Execution is about to time out, sending failure response to CloudFormation') 311 | cfnresponse.send(event, context, cfnresponse.FAILED, {}, None) 312 | def handler(event, context): 313 | # make sure we send a failure to CloudFormation if the function 314 | # is going to timeout 315 | timer = threading.Timer((context.get_remaining_time_in_millis() 316 | / 1000.00) - 0.5, timeout, args=[event, context]) 317 | timer.start() 318 | print('Received event: %s' % json.dumps(event)) 319 | status = cfnresponse.SUCCESS 320 | try: 321 | source_bucket = event['ResourceProperties']['SourceBucket'] 322 | dest_bucket = event['ResourceProperties']['DestBucket'] 323 | prefix = event['ResourceProperties']['Prefix'] 324 | objects = event['ResourceProperties']['Objects'] 325 | if event['RequestType'] == 'Delete': 326 | delete_objects(dest_bucket, prefix, objects) 327 | else: 328 | copy_objects(source_bucket, dest_bucket, prefix, objects) 329 | except Exception as e: 330 | logging.error('Exception: %s' % e, exc_info=True) 331 | status = cfnresponse.FAILED 332 | finally: 333 | timer.cancel() 334 | cfnresponse.send(event, context, status, {}, None) 335 | --------------------------------------------------------------------------------