├── .github └── workflows │ └── main.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── cloudformation ├── guardduty-event-normalization.yml ├── guardduty-invitation-manager.yml ├── guardduty-member-account-role.yml └── guardduty-multi-account-manager-parent.yml ├── docs ├── dgram.png ├── example-organizations-reader-iam-role.yml └── plan.md ├── lambda_functions ├── __init__.py ├── invitation_manager.py ├── normalization.py └── plumbing.py ├── requirements.txt └── tests └── .keep /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | name: Check CloudFormation syntax with cfn-lint 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2.2.2 13 | with: 14 | python-version: 3.9 15 | - run: pip install --requirement $GITHUB_WORKSPACE/requirements.txt 16 | - run: make cfn-lint 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ROOT_DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) 2 | PARENTDIR := $(realpath ../) 3 | AWS_REGION = us-west-2 4 | # We only need to publish to the us-west-2 bucket as it uses bucket replication 5 | # to replicate all data to the buckets in other regions 6 | S3_BUCKET_NAME := public.us-west-2.infosec.mozilla.org 7 | S3_BUCKET_TEMPLATE_PATH := guardduty-multi-account-manager/cf 8 | S3_BUCKET_LAMBDA_PATH := guardduty-multi-account-manager/lambda 9 | S3_BUCKET_TEMPLATE_URI := s3://$(S3_BUCKET_NAME)/$(S3_BUCKET_TEMPLATE_PATH) 10 | S3_BUCKET_LAMBDA_URI := s3://$(S3_BUCKET_NAME)/$(S3_BUCKET_LAMBDA_PATH) 11 | PARENT_TEMPLATE_URI := https://s3.amazonaws.com/$(S3_BUCKET_NAME)/$(S3_BUCKET_TEMPLATE_PATH)/guardduty-multi-account-manager-parent.yml 12 | LAMBDA_BUCKET_PREFIX := $(shell v='$(S3_BUCKET_NAME)'; echo "$${v%%us-west-2*}") 13 | LAMBDA_BUCKET_SUFFIX := $(shell v='$(S3_BUCKET_NAME)'; echo "$${v\#\#*us-west-2}") 14 | 15 | all: 16 | @echo 'Available make targets:' 17 | @grep '^[^#[:space:]].*:' Makefile 18 | 19 | .PHONY: test 20 | test: 21 | py.test tests/ --capture=no 22 | 23 | .PHONY: cfn-lint test 24 | test: cfn-lint 25 | cfn-lint: ## Verify the CloudFormation template pass linting tests 26 | -cfn-lint cloudformation/*.yml 27 | 28 | .PHONY: upload-templates 29 | upload-templates: 30 | @export AWS_REGION=$(AWS_REGION) 31 | aws s3 sync cloudformation/ $(S3_BUCKET_TEMPLATE_URI) --exclude="*" --include="*.yml" 32 | 33 | .PHONY: upload-normalization-lambda 34 | upload-normalization-lambda: 35 | @export AWS_REGION=$(AWS_REGION) 36 | zip lambda_functions/normalization.zip lambda_functions/normalization.py 37 | aws s3 cp lambda_functions/normalization.zip $(S3_BUCKET_LAMBDA_URI)/normalization.zip 38 | rm lambda_functions/normalization.zip 39 | 40 | .PHONY: upload-plumbing-lambda 41 | upload-plumbing-lambda: 42 | @export AWS_REGION=$(AWS_REGION) 43 | zip lambda_functions/plumbing.zip lambda_functions/plumbing.py 44 | aws s3 cp lambda_functions/plumbing.zip $(S3_BUCKET_LAMBDA_URI)/plumbing.zip 45 | rm lambda_functions/plumbing.zip 46 | 47 | .PHONY: upload-invitation_manager-lambda 48 | upload-invitation_manager-lambda: 49 | zip lambda_functions/invitation_manager.zip lambda_functions/invitation_manager.py 50 | AWS_REGION=$(AWS_REGION) aws s3 cp lambda_functions/invitation_manager.zip $(S3_BUCKET_LAMBDA_URI)/invitation_manager.zip 51 | rm lambda_functions/invitation_manager.zip 52 | 53 | .PHONY: upload-templates create-stack 54 | create-stack: 55 | @export AWS_REGION=$(AWS_REGION) 56 | 57 | # $${$(S3_BUCKET_NAME)##us-west-2*} 58 | # https://github.com/aws/aws-cli/issues/870#issuecomment-51629161 59 | aws cloudformation create-stack --stack-name guardduty-multi-account-manager \ 60 | --capabilities CAPABILITY_IAM \ 61 | --parameters \ 62 | ParameterKey=CloudFormationTemplatePrefix,ParameterValue=https://s3.amazonaws.com/$(S3_BUCKET_NAME)/$(S3_BUCKET_TEMPLATE_PATH)/ \ 63 | ParameterKey=LambdaCodeS3BucketNamePrefix,ParameterValue=$(LAMBDA_BUCKET_PREFIX) \ 64 | ParameterKey=LambdaCodeS3BucketNameSuffix,ParameterValue=$(LAMBDA_BUCKET_SUFFIX) \ 65 | ParameterKey=LambdaCodeS3Path,ParameterValue=$(S3_BUCKET_LAMBDA_PATH)/ \ 66 | ParameterKey=OrganizationAccountArns,ParameterValue=\'$(ORGANIZAION_ACCOUNT_ARNS)\' \ 67 | ParameterKey=AccountFilterList,ParameterValue=$(ACCOUNT_FILTER_LIST) \ 68 | --template-url $(PARENT_TEMPLATE_URI) 69 | 70 | .PHONY: create-invitation-manager-stack 71 | create-invitation-manager-stack: 72 | AWS_REGION=$(AWS_REGION) aws cloudformation create-stack --stack-name guardduty-invitation-manager \ 73 | --capabilities CAPABILITY_IAM \ 74 | --parameters \ 75 | ParameterKey=LambdaCodeS3BucketNamePrefix,ParameterValue=$(LAMBDA_BUCKET_PREFIX) \ 76 | ParameterKey=LambdaCodeS3BucketNameSuffix,ParameterValue=$(LAMBDA_BUCKET_SUFFIX) \ 77 | ParameterKey=LambdaCodeS3Path,ParameterValue=$(S3_BUCKET_LAMBDA_PATH)/ \ 78 | ParameterKey=OrganizationAccountArns,ParameterValue=\'$(ORGANIZAION_ACCOUNT_ARNS)\' \ 79 | ParameterKey=AccountFilterList,ParameterValue=$(ACCOUNT_FILTER_LIST) \ 80 | --template-url https://s3.amazonaws.com/$(S3_BUCKET_NAME)/$(S3_BUCKET_TEMPLATE_PATH)/guardduty-invitation-manager.yml 81 | 82 | .PHONY: upload-templates update-stack 83 | update-stack: 84 | @export AWS_REGION=$(AWS_REGION) 85 | aws cloudformation update-stack --stack-name guardduty-multi-account-manager \ 86 | --capabilities CAPABILITY_IAM \ 87 | --template-url $(PARENT_TEMPLATE_URI) 88 | 89 | 90 | # To upload to staging and test 91 | # logged into infosec-dev AWS account 92 | # make upload-templates S3_BUCKET_NAME=public.us-west-2.security.allizom.org 93 | # make upload-invitation_manager-lambda S3_BUCKET_NAME=public.us-west-2.security.allizom.org 94 | # logged into infosec-prod (because this is the account that's trusted) 95 | # make create-stack S3_BUCKET_NAME=public.us-west-2.security.allizom.org 96 | # or 97 | # make create-invitation-manager-stack S3_BUCKET_NAME=public.us-west-2.security.allizom.org ORGANIZAION_ACCOUNT_ARNS=arn:aws:iam::329567179436:role/Infosec-Organization-Reader,arn:aws:iam::943761894018:role/Infosec-Organization-Reader 98 | 99 | 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GuardDuty Multi-Account Manager 2 | 3 | Automate the AWS GuardDuty account invitation lifecycle for all of your 4 | organizations AWS accounts in all regions as well as aggregate and normalize 5 | the GuardDuty findings 6 | 7 | ## Architecture 8 | 9 | !['docs/dgram.png'](docs/dgram.png) 10 | 11 | > Above is an example architecture for a master account with a member account. 12 | > Note: The member account has GuardDuty detectors in every region as does the 13 | > master account. 14 | 15 | ## Why This? 16 | 17 | As a multi-account user of Amazon Web Services you have a few choices when 18 | deciding to turn on GuardDuty across your accounts. 19 | 20 | Your options are: 21 | 22 | 1. Stack Sets 23 | 2. Human invitations 24 | 3. Something else. 25 | 26 | Due to the nature of stack sets and the distributed governance of Mozilla it 27 | breaks our trust model to grant the needed permissions to run stack sets. 28 | Human behavior consistently generates inconsistent results. 29 | 30 | This is why we elected to create GuardDuty Multi-Account Manager 31 | 32 | ## What is it? 33 | 34 | GuardDuty Multi-Account Manager is a series of AWS Lambda functions designed to 35 | do the following: 36 | 37 | * Enable GuardDuty Masters in all AWS Regions present and future. 38 | * Empower account owners to decide to enable GuardDuty 39 | * Manage the lifecycle of invitations to the member accounts 40 | * Aggregate all findings from all detectors in all regions, normalize the data, 41 | and send to a single SQS queue 42 | 43 | ## How do I deploy it? 44 | 45 | ### Dependencies 46 | 47 | * AWS Organizations 48 | * Either run the GuardDuty Multi-Account Manager from within an AWS 49 | Organizations parent account or 50 | * Establish an IAM Role in the AWS Organizations parent account that can be 51 | assumed by the GuardDuty Multi-Account Manager. 52 | [Example IAM Role](docs/example-organizations-reader-iam-role.yml) 53 | * Deploy the 54 | [Cloudformation Cross Account Outputs](https://github.com/mozilla/cloudformation-cross-account-outputs/) 55 | service which allows CloudFormation stacks in other AWS accounts to report 56 | back output. This is used to convey the 57 | [GuardDuty Member Account IAM Role](cloudformation/guardduty-member-account-role.yml) 58 | information. In order to deploy this service 59 | [follow the instructions in the README](https://github.com/mozilla/cloudformation-cross-account-outputs#deploy-the-infrastructure) 60 | which explains how. 61 | * Make sure that in Step 1 and 2 you deploy each template in only one region. These resources shouldn't be deployed multiple times in an AWS account. 62 | * Make sure that in Step 3, you deploy the `cloudformation-sns-emission-consumer.yml` 63 | template in every region that you want to allow your GuardDuty members to potentially 64 | deploy the GuardDuty member role in. For example, in the included 65 | [`guardduty-member-account-role.yml`](cloudformation/guardduty-member-account-role.yml), 66 | it assumes that you'll have deployed `cloudformation-sns-emission-consumer.yml` 67 | in both `us-west-2` and `us-east-1` 68 | * Customize the 69 | [`guardduty-member-account-role.yml`](cloudformation/guardduty-member-account-role.yml) 70 | CloudFormation template which you'll distribute to your members. 71 | * You need to set two values in the `Mappings` section of the template 72 | * `MasterAccount`:`Principal` : Set this to the 73 | [root principal](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html#Principal_specifying) 74 | of your AWS account in which you're running the GuardDuty master. For 75 | example `arn:aws:iam::123456789012:root` 76 | * `SNSTopicForPublishingIAMRoleArn`:`Account` : Set this to the 77 | [AWS Account ID](https://docs.aws.amazon.com/general/latest/gr/acct-identifiers.html#FindingYourAccountIdentifiers) 78 | of the AWS account that you've deployed the 79 | [Cloudformation Cross Account Outputs](https://github.com/mozilla/cloudformation-cross-account-outputs/) 80 | service in. For example `123456789012`. 81 | * Add any additional regions that you wish to support (which you've deployed 82 | Cloudformation Cross Account Outputs in) into the 83 | `TheRegionYouAreDeployingIn` mapping following the example of the existing 84 | two regions listed there already. 85 | 86 | ### Getting Started 87 | 88 | If you want just the account management functionality : 89 | 90 | * Deploy the Cloudformation Stack from 91 | [`cloudformation/guardduty-invitation-manager.yml`](https://s3-us-west-2.amazonaws.com/public.us-west-2.infosec.mozilla.org/guardduty-multi-account-manager/cf/guardduty-invitation-manager.yml) in the master 92 | account. [![Launch GuardDuty Multi Account Manager](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/new?stackName=guardduty-invitation-manager&templateURL=https://s3-us-west-2.amazonaws.com/public.us-west-2.infosec.mozilla.org/guardduty-multi-account-manager/cf/guardduty-invitation-manager.yml) 93 | 94 | If you want the account management and centralized, normalized GuardDuty data in 95 | an SQS queue 96 | 97 | * Deploy the Cloudformation Stack from 98 | [`cloudformation/guardduty-multi-account-manager-parent.yml`](https://s3-us-west-2.amazonaws.com/public.us-west-2.infosec.mozilla.org/guardduty-multi-account-manager/cf/guardduty-multi-account-manager-parent.yml) in the master 99 | account. [![Launch GuardDuty Multi Account Manager](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/new?stackName=guardduty-multi-account-manager&templateURL=https://s3-us-west-2.amazonaws.com/public.us-west-2.infosec.mozilla.org/guardduty-multi-account-manager/cf/guardduty-multi-account-manager-parent.yml) 100 | 101 | ### Onboarding Accounts 102 | 103 | 1. Ensure that the mappings are configured in the 104 | [`cloudformation/guardduty-member-account-role.yml`](cloudformation/guardduty-member-account-role.yml) 105 | template as described above 106 | 2. Deploy the customized [`cloudformation/guardduty-member-account-role.yml`](cloudformation/guardduty-member-account-role.yml) 107 | CloudFormation template in your member AWS accounts. This CloudFormation template should only be deployed once in a single 108 | region in each member AWS account. The account will then register with the master account and go through the invitation 109 | process automatically for every region. 110 | 111 | ## AWS re:invent 2018 SEC403 Presentation 112 | 113 | * [Watch our presentation on GuardDuty Multi Account Manager](https://www.youtube.com/watch?v=M5yQpegaYF8&t=1889) at AWS re:Invent 2018 114 | * [Read the slides](https://www.slideshare.net/AmazonWebServices/five-new-security-automations-using-aws-security-services-open-source-sec403-aws-reinvent-2018/47) 115 | 116 | ## License 117 | 118 | guardduty-multi-account-manager is Licensed under the 119 | [Mozilla Public License 2.0 ( MPL2.0 )](https://www.mozilla.org/en-US/MPL/2.0/) 120 | 121 | ## Contributors 122 | 123 | * [Gene Wood](https://github.com/gene1wood/) 124 | * [Andrew Krug](https://github.com/andrewkrug/) 125 | -------------------------------------------------------------------------------- /cloudformation/guardduty-event-normalization.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: Mozilla Multi Account Manager formerly known as guardDuty2MozDef subscribes to gd cloudwatch events and outputs MozDef format message in an SQS queue. 3 | Metadata: 4 | Source: https://github.com/mozilla/guardduty-multi-account-manager/tree/master/cloudformation 5 | Parameters: 6 | LambdaCodeS3BucketNamePrefix: 7 | Type: String 8 | Default: public. 9 | Description: > 10 | A prefix string describing the S3 bucket name in each region containing 11 | the normalization.zip and plumbing.zip Lambda code. This parameter is appended with 12 | the region name. For example my-bucket- would be turned into 13 | my-bucket-us-west-2 for us-west-2. Set this to blank if your bucket name 14 | has no prefix. 15 | LambdaCodeS3BucketNameSuffix: 16 | Type: String 17 | Default: .infosec.mozilla.org 18 | Description: > 19 | A suffix string describing the S3 bucket name in each region containing 20 | the normalization.zip and plumbing.zip Lambda code. This parameter is prepended with 21 | the region name. For example -my-bucket would be turned into 22 | us-west-2-my-bucket for us-west-2. Set this to blank if your bucket name 23 | has no suffix 24 | LambdaCodeS3Path: 25 | Type: String 26 | Default: guardduty-multi-account-manager/lambda/ 27 | Description: > 28 | The path in the S3 bucket containing the Lambda code. 29 | AllowedPattern: '.*\/$' 30 | ConstraintDescription: A path ending in the / character 31 | Resources: 32 | GuardDutyToMozDefRole: 33 | Type: AWS::IAM::Role 34 | Properties: 35 | AssumeRolePolicyDocument: 36 | Statement: 37 | - Effect: Allow 38 | Principal: 39 | Service: 40 | - lambda.amazonaws.com 41 | Action: 42 | - sts:AssumeRole 43 | Path: /service-role/ 44 | ManagedPolicyArns: 45 | - arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess 46 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 47 | - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole 48 | Policies: 49 | - 50 | PolicyName: "allow-sns-topic-publishing" 51 | PolicyDocument: 52 | Version: "2012-10-17" 53 | Statement: 54 | - 55 | Effect: "Allow" 56 | Action: "sns:Publish" 57 | Resource: !Ref SnsOutputTopic 58 | GuardDutyPlumbingRole: 59 | Type: AWS::IAM::Role 60 | Properties: 61 | AssumeRolePolicyDocument: 62 | Statement: 63 | - Effect: Allow 64 | Principal: 65 | Service: 66 | - lambda.amazonaws.com 67 | Action: 68 | - sts:AssumeRole 69 | Path: /service-role/ 70 | ManagedPolicyArns: 71 | - arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess 72 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 73 | - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole 74 | - arn:aws:iam::aws:policy/AmazonSNSFullAccess 75 | - arn:aws:iam::aws:policy/CloudWatchEventsFullAccess 76 | - arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess 77 | SqsOutput: 78 | Type: "AWS::SQS::Queue" 79 | SnsOutputTopic: 80 | Type: "AWS::SNS::Topic" 81 | Properties: 82 | Subscription: 83 | - 84 | Endpoint: 85 | Fn::GetAtt: 86 | - "SqsOutput" 87 | - "Arn" 88 | Protocol: "sqs" 89 | SqsQueuePolicy: 90 | Type: AWS::SQS::QueuePolicy 91 | Properties: 92 | PolicyDocument: 93 | Version: '2012-10-17' 94 | Id: MyQueuePolicy 95 | Statement: 96 | - Sid: Allow-SendMessage-To-The-Queue-From-SNS-Topic 97 | Effect: Allow 98 | Principal: '*' 99 | Action: 100 | - sqs:SendMessage 101 | Resource: '*' 102 | Condition: 103 | ArnEquals: 104 | aws:SourceArn: !Ref SnsOutputTopic 105 | Queues: 106 | - !Ref SqsOutput 107 | LambdaInvokePermission1: 108 | Type: AWS::Lambda::Permission 109 | Properties: 110 | Action: lambda:InvokeFunction 111 | Principal: events.amazonaws.com 112 | FunctionName: !GetAtt 'findingsToMozDef.Arn' 113 | LambdaInvokePermission2: 114 | Type: AWS::Lambda::Permission 115 | Properties: 116 | Action: lambda:InvokeFunction 117 | Principal: sns.amazonaws.com 118 | FunctionName: !GetAtt 'findingsToMozDef.Arn' 119 | findingsToMozDef: 120 | Type: AWS::Lambda::Function 121 | Properties: 122 | Runtime: 'python3.9' 123 | Timeout: 300 124 | Handler: lambda_functions/normalization.handle 125 | Role: !GetAtt 'GuardDutyToMozDefRole.Arn' 126 | Code: 127 | S3Bucket: !Join [ '', [ !Ref LambdaCodeS3BucketNamePrefix, !Ref 'AWS::Region', !Ref LambdaCodeS3BucketNameSuffix ] ] 128 | S3Key: !Join [ '', [ !Ref LambdaCodeS3Path, "normalization.zip" ] ] 129 | Environment: 130 | Variables: 131 | minSeverityLevel: 'LOW' 132 | SNS_OUTPUT_TOPIC_ARN: !Ref SnsOutputTopic 133 | gdPlumbing: 134 | Type: AWS::Lambda::Function 135 | Properties: 136 | Runtime: 'python3.9' 137 | Timeout: 300 138 | Handler: lambda_functions/plumbing.handle 139 | Role: !GetAtt 'GuardDutyPlumbingRole.Arn' 140 | Code: 141 | S3Bucket: !Join [ '', [ !Ref LambdaCodeS3BucketNamePrefix, !Ref 'AWS::Region', !Ref LambdaCodeS3BucketNameSuffix ] ] 142 | S3Key: !Join [ '', [ !Ref LambdaCodeS3Path, "plumbing.zip" ] ] 143 | Environment: 144 | Variables: 145 | NORMALIZER_LAMBDA_FUNCTION: !GetAtt findingsToMozDef.Arn 146 | PlumbingScheduledRule: 147 | Type: AWS::Events::Rule 148 | Properties: 149 | Description: "Trigger GuardDuty plumbing once per hour" 150 | ScheduleExpression: "rate(1 hour)" 151 | State: "ENABLED" 152 | Targets: 153 | - 154 | Arn: 155 | Fn::GetAtt: 156 | - "gdPlumbing" 157 | - "Arn" 158 | Id: "TargetFunctionV1" 159 | PermissionForEventsToInvokeLambda: 160 | Type: AWS::Lambda::Permission 161 | Properties: 162 | FunctionName: 163 | Ref: gdPlumbing 164 | Action: "lambda:InvokeFunction" 165 | Principal: "events.amazonaws.com" 166 | SourceArn: 167 | Fn::GetAtt: 168 | - PlumbingScheduledRule 169 | - Arn 170 | Outputs: 171 | NormalizationLambdaFunction: 172 | Description: The arn of the normalizer lambda. 173 | Value: !GetAtt findingsToMozDef.Arn 174 | OutputQueue: 175 | Description: Where do the normalized events end up. (SQS arn) 176 | Value: !Ref SqsOutput 177 | -------------------------------------------------------------------------------- /cloudformation/guardduty-invitation-manager.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: Mozilla Multi Account Manager invitation manager lambda function to run in master account. 3 | Metadata: 4 | Source: https://github.com/mozilla/guardduty-multi-account-manager/tree/master/cloudformation 5 | Parameters: 6 | AccountFilterList: 7 | Type: String 8 | Default: '' 9 | Description: > 10 | Space delimited list of account IDs to filter on. If this is set, only 11 | these accounts will be processed. If this is empty all accounts will be 12 | processed. 13 | OrganizationAccountArns: 14 | Type: String 15 | Default: '' 16 | Description: > 17 | Comma delimited list of ARNs of IAM Roles to assume in order to query the 18 | AWS Organization parents if the Org parents are different AWS accounts. 19 | Leave this empty if you are deploying this stack within a single AWS 20 | Organization parent account. 21 | LambdaCodeS3BucketNamePrefix: 22 | Type: String 23 | Default: public. 24 | Description: > 25 | A prefix string describing the S3 bucket name in each region containing 26 | the normalization.zip and plumbing.zip Lambda code. This parameter is appended with 27 | the region name. For example my-bucket- would be turned into 28 | my-bucket-us-west-2 for us-west-2. Set this to blank if your bucket name 29 | has no prefix. 30 | LambdaCodeS3BucketNameSuffix: 31 | Type: String 32 | Default: .infosec.mozilla.org 33 | Description: > 34 | A suffix string describing the S3 bucket name in each region containing 35 | the normalization.zip and plumbing.zip Lambda code. This parameter is prepended with 36 | the region name. For example -my-bucket would be turned into 37 | us-west-2-my-bucket for us-west-2. Set this to blank if your bucket name 38 | has no suffix 39 | LambdaCodeS3Path: 40 | Type: String 41 | Default: guardduty-multi-account-manager/lambda/ 42 | Description: > 43 | The path in the S3 bucket containing the Lambda code. 44 | AllowedPattern: '.*\/$' 45 | ConstraintDescription: A path ending in the / character 46 | Mappings: 47 | Variables: 48 | DynamoDBTable: 49 | Name: cloudformation-stack-emissions 50 | Category: GuardDuty Multi Account Member Role 51 | Resources: 52 | InvitationManagerIAMRole: 53 | Type: AWS::IAM::Role 54 | Properties: 55 | AssumeRolePolicyDocument: 56 | Statement: 57 | - Effect: Allow 58 | Principal: 59 | Service: 60 | - lambda.amazonaws.com 61 | Action: 62 | - sts:AssumeRole 63 | ManagedPolicyArns: 64 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 65 | Policies: 66 | - PolicyName: "AllowRoleAssumptionOfMembers" 67 | PolicyDocument: 68 | Version: "2012-10-17" 69 | Statement: 70 | - Effect: "Allow" 71 | Action: sts:AssumeRole 72 | Resource: 73 | - arn:aws:iam::*:role/multi-account-guard-duty/* 74 | - arn:aws:iam::*:role/mutli-account-guard-duty/* 75 | - PolicyName: "AllowRoleAssumptionOfOrgReaders" 76 | PolicyDocument: 77 | Version: "2012-10-17" 78 | Statement: 79 | - Effect: "Allow" 80 | Action: sts:AssumeRole 81 | Resource: !Split [ ",", !Ref OrganizationAccountArns ] 82 | - PolicyName: "AllowSTSGetCallerIdentity" 83 | PolicyDocument: 84 | Version: "2012-10-17" 85 | Statement: 86 | - Effect: "Allow" 87 | Action: sts:GetCallerIdentity 88 | Resource: '*' 89 | - PolicyName: "AllowLocalGuardDuty" 90 | PolicyDocument: 91 | Version: "2012-10-17" 92 | Statement: 93 | - Effect: "Allow" 94 | Action: 95 | - guardduty:CreateDetector 96 | - guardduty:ListDetectors 97 | Resource: '*' 98 | - PolicyName: "AllowGuardDutyMasterManagement" 99 | PolicyDocument: 100 | Version: "2012-10-17" 101 | Statement: 102 | - Effect: "Allow" 103 | Action: 104 | - guardduty:GetMembers 105 | - guardduty:ListMembers 106 | - guardduty:CreateMembers 107 | - guardduty:InviteMembers 108 | Resource: '*' 109 | - PolicyName: "AllowOrganizationListAccounts" 110 | PolicyDocument: 111 | Version: "2012-10-17" 112 | Statement: 113 | - Effect: "Allow" 114 | Action: organizations:ListAccounts 115 | Resource: '*' 116 | - PolicyName: "AllowScanDynamoDB" 117 | PolicyDocument: 118 | Version: "2012-10-17" 119 | Statement: 120 | - Effect: "Allow" 121 | Action: dynamodb:Scan 122 | Resource: !Join [ '', [ 'arn:aws:dynamodb:*:', !Ref 'AWS::AccountId', ':table/', !FindInMap [ Variables, DynamoDBTable, Name ]]] 123 | InvitationManagerFunction: 124 | Type: AWS::Lambda::Function 125 | Properties: 126 | Runtime: python3.9 127 | Timeout: 900 128 | Handler: lambda_functions/invitation_manager.handle 129 | Role: !GetAtt InvitationManagerIAMRole.Arn 130 | Code: 131 | S3Bucket: !Join [ '', [ !Ref LambdaCodeS3BucketNamePrefix, !Ref 'AWS::Region', !Ref LambdaCodeS3BucketNameSuffix ] ] 132 | S3Key: !Join [ '', [ !Ref LambdaCodeS3Path, "invitation_manager.zip" ] ] 133 | Environment: 134 | Variables: 135 | ACCOUNT_FILTER_LIST: !Ref AccountFilterList 136 | DYNAMODB_TABLE_NAME: !FindInMap [ Variables, DynamoDBTable, Name ] 137 | DB_CATEGORY: !FindInMap [ Variables, DynamoDBTable, Category ] 138 | ORGANIZATION_IAM_ROLE_ARNS: !Ref OrganizationAccountArns 139 | InvitationManagerScheduledRule: 140 | Type: AWS::Events::Rule 141 | Properties: 142 | Description: "Trigger GuardDuty Invitation Manager run thrice daily" 143 | ScheduleExpression: "cron(14,29,44 11 * * ? *)" 144 | State: "ENABLED" 145 | Targets: 146 | - Arn: !GetAtt InvitationManagerFunction.Arn 147 | Id: "InvitationManager" 148 | PermissionForEventsToInvokeLambda: 149 | Type: AWS::Lambda::Permission 150 | Properties: 151 | FunctionName: 152 | Ref: InvitationManagerFunction 153 | Action: "lambda:InvokeFunction" 154 | Principal: "events.amazonaws.com" 155 | SourceArn: !GetAtt InvitationManagerScheduledRule.Arn 156 | -------------------------------------------------------------------------------- /cloudformation/guardduty-member-account-role.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: Mozilla Multi Account Manager member account roles to allow guardDuty master to accept invitations. 3 | Metadata: 4 | Source: https://github.com/mozilla/guardduty-multi-account-manager/tree/master/cloudformation 5 | Mappings: 6 | Variables: 7 | MasterAccount: 8 | # Make sure to set this Principal variable to the account running your 9 | # GuardDuty master. Leaving it as the default will cause stack failure 10 | # as this example value is an invalid reserved principal 11 | Principal: arn:aws:iam::123456789012:root 12 | SNSTopicForPublishingIAMRoleArn: 13 | # Make sure to set this Account variable to the account ID running your CloudFormation Cross Account Output infrastructure 14 | Account: 123456789012 15 | Topic: cloudformation-stack-emissions 16 | TheRegionYouAreDeployingIn: 17 | # Create a record for each region in which CloudFormation Cross Account Output infrastructure is deployed 18 | us-east-1: 19 | WhatIsThisMapping: This constrains the regions in which you can deploy this template to only the regions listed in this mapping. This, for example, prevents deloying in ap-south-1 20 | IsNotSupportedPleaseUseADifferentRegion: True 21 | us-west-2: 22 | WhatIsThisMapping: '' 23 | IsNotSupportedPleaseUseADifferentRegion: True 24 | Conditions: 25 | RunningInAllowedRegion: !Equals [ !FindInMap [ TheRegionYouAreDeployingIn, !Ref 'AWS::Region', IsNotSupportedPleaseUseADifferentRegion ] , True ] 26 | Resources: 27 | GuardDutyInvitationAcceptorIAMRole: 28 | Type: AWS::IAM::Role 29 | Properties: 30 | AssumeRolePolicyDocument: 31 | Version: 2012-10-17 32 | Statement: 33 | - Effect: Allow 34 | Principal: 35 | AWS: !FindInMap [ Variables, MasterAccount, Principal ] 36 | Action: sts:AssumeRole 37 | Policies: 38 | - PolicyName: AllowAcceptingGuardDutyInvitation 39 | PolicyDocument: 40 | Version: 2012-10-17 41 | Statement: 42 | - Effect: Allow 43 | Action: 44 | - guardduty:ListDetectors 45 | - guardduty:CreateDetector 46 | - guardduty:DeleteDetector 47 | - guardduty:AcceptInvitation 48 | - guardduty:DeleteInvitations 49 | - guardduty:GetDetector 50 | - guardduty:GetInvitationsCount 51 | - guardduty:GetMasterAccount 52 | - guardduty:UpdateDetector 53 | - guardduty:ListInvitations 54 | - guardduty:DisassociateFromMasterAccount 55 | Resource: '*' 56 | Path: '/multi-account-guard-duty/' 57 | PublishIAMRoleArnsToSNS: 58 | Type: Custom::PublishIAMRoleArnsToSNS 59 | Version: '1.0' 60 | Properties: 61 | ServiceToken: !Join [ ':', [ 'arn:aws:sns', !Ref 'AWS::Region', !FindInMap [ Variables, SNSTopicForPublishingIAMRoleArn, Account ], !FindInMap [ Variables, SNSTopicForPublishingIAMRoleArn, Topic ] ] ] 62 | category: GuardDuty Multi Account Member Role 63 | GuardDutyMemberAccountIAMRoleArn: !GetAtt GuardDutyInvitationAcceptorIAMRole.Arn 64 | GuardDutyMemberAccountIAMRoleName: !Ref GuardDutyInvitationAcceptorIAMRole 65 | CloudFormationLambdaIAMRole: 66 | Type: AWS::IAM::Role 67 | Properties: 68 | AssumeRolePolicyDocument: 69 | Version: 2012-10-17 70 | Statement: 71 | - Effect: Allow 72 | Principal: 73 | Service: 74 | - lambda.amazonaws.com 75 | Action: 76 | - sts:AssumeRole 77 | Policies: 78 | - PolicyName: AllowLambdaLoggingAndCreateServiceLinkedRole 79 | PolicyDocument: 80 | Version: 2012-10-17 81 | Statement: 82 | - 83 | Effect: Allow 84 | Action: 85 | - logs:* 86 | - iam:CreateServiceLinkedRole 87 | Resource: '*' 88 | CreateGuardDutyServiceLinkedRoleFunction: 89 | Type: AWS::Lambda::Function 90 | Properties: 91 | Code: 92 | ZipFile: | 93 | import cfnresponse 94 | import boto3, secrets, string 95 | 96 | 97 | def handler(event, context): 98 | client = boto3.client('iam') 99 | created = False 100 | try: 101 | if event['RequestType'] in ['Create', 'Update']: 102 | client.create_service_linked_role( 103 | AWSServiceName='guardduty.amazonaws.com', 104 | Description='A service-linked role required for Amazon ' 105 | 'GuardDuty to access your resources.' 106 | ) 107 | created = True 108 | elif event['RequestType'] == 'Delete': 109 | pass # Leave the role in place when stack is deleted 110 | except client.exceptions.InvalidInputException: 111 | pass # Role already exists 112 | physical_id = ''.join( 113 | secrets.choice(string.ascii_uppercase + string.digits) for _ in 114 | range(13)) 115 | cfnresponse.send( 116 | event, context, cfnresponse.SUCCESS, {'RoleCreated': created}, 117 | "CreateGuardDutyServiceLinkedRole-%s" % physical_id) 118 | Handler: index.handler 119 | Runtime: python3.6 120 | Role: !GetAtt CloudFormationLambdaIAMRole.Arn 121 | Timeout: 20 122 | CreateGuardDutyServiceLinkedRole: 123 | Type: AWS::CloudFormation::CustomResource 124 | Properties: 125 | ServiceToken: !GetAtt CreateGuardDutyServiceLinkedRoleFunction.Arn 126 | Outputs: 127 | GuardDutyInvitationAcceptorRoleName: 128 | Description: IAM Role name of the Guard Duty Invitation Acceptor 129 | Value: !Ref GuardDutyInvitationAcceptorIAMRole 130 | GuardDutyInvitationAcceptorRoleArn: 131 | Description: ARN of the Guard Duty Invitation Acceptor IAM Role 132 | Value: !GetAtt GuardDutyInvitationAcceptorIAMRole.Arn 133 | -------------------------------------------------------------------------------- /cloudformation/guardduty-multi-account-manager-parent.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: Mozilla Multi Account Manager for AWS GuardDuty 3 | Metadata: 4 | Source: https://github.com/mozilla/guardduty-multi-account-manager/tree/master/cloudformation 5 | TemplateVersion: 1.0.1 6 | Parameters: 7 | AccountFilterList: 8 | Type: String 9 | Default: '' 10 | Description: > 11 | Space delimited list of account IDs to filter on. Leave this empty to 12 | process all accounts 13 | OrganizationAccountArns: 14 | Type: String 15 | Default: '' 16 | Description: > 17 | ARNs of IAM Roles to assume in order to query the AWS Organization parents 18 | if the Org parents are different AWS accounts. Leave this empty if you are 19 | deploying within a single AWS Organization parent account 20 | CloudFormationTemplatePrefix: 21 | Type: String 22 | Default: https://s3-us-west-2.amazonaws.com/public.us-west-2.infosec.mozilla.org/guardduty-multi-account-manager/cf/ 23 | Description: > 24 | The S3 bucket and path containing the child CloudFormation templates 25 | LambdaCodeS3BucketNamePrefix: 26 | Type: String 27 | Default: public. 28 | Description: > 29 | The section of the S3 bucket name preceding the region. For example the 30 | bucket "public.us-west-2.infosec.mozilla.org" would have a prefix of 31 | "public." 32 | LambdaCodeS3BucketNameSuffix: 33 | Type: String 34 | Default: .infosec.mozilla.org 35 | Description: > 36 | The section of the S3 bucket name succeeds the region. For example the 37 | bucket "public.us-west-2.infosec.mozilla.org" would have a suffix of 38 | ".infosec.mozilla.org" 39 | LambdaCodeS3Path: 40 | Type: String 41 | Default: guardduty-multi-account-manager/lambda/ 42 | Description: > 43 | The directory path in the Lambda code bucket containing the Lambda code 44 | Resources: 45 | GuardDutyInvitationManager: 46 | Type: AWS::CloudFormation::Stack 47 | Properties: 48 | Tags: 49 | - Key: application 50 | Value: guardduty-multi-account-manager 51 | TemplateURL: !Join [ '', [ !Ref 'CloudFormationTemplatePrefix', guardduty-invitation-manager.yml ] ] 52 | Parameters: 53 | LambdaCodeS3BucketNamePrefix: !Ref LambdaCodeS3BucketNamePrefix 54 | LambdaCodeS3BucketNameSuffix: !Ref LambdaCodeS3BucketNameSuffix 55 | LambdaCodeS3Path: !Ref LambdaCodeS3Path 56 | AccountFilterList: !Ref AccountFilterList 57 | OrganizationAccountArns: !Ref OrganizationAccountArns 58 | GuardDutyEventNormalization: 59 | Type: AWS::CloudFormation::Stack 60 | Properties: 61 | Tags: 62 | - Key: application 63 | Value: guardduty-multi-account-manager 64 | TemplateURL: !Join [ '', [ !Ref 'CloudFormationTemplatePrefix', guardduty-event-normalization.yml ] ] 65 | Parameters: 66 | LambdaCodeS3BucketNamePrefix: !Ref LambdaCodeS3BucketNamePrefix 67 | LambdaCodeS3BucketNameSuffix: !Ref LambdaCodeS3BucketNameSuffix 68 | LambdaCodeS3Path: !Ref LambdaCodeS3Path 69 | -------------------------------------------------------------------------------- /docs/dgram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/guardduty-multi-account-manager/15b20e6993f428a51ba66b94e11a891075c70620/docs/dgram.png -------------------------------------------------------------------------------- /docs/example-organizations-reader-iam-role.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: IAM Role that grants permission to read AWS Organizations 3 | AWS Organization information 4 | Mappings: 5 | VariablesMap: 6 | TrustedAccount: 7 | ExternalAccountId: '123456789012' 8 | RoleName: Organization-Reader 9 | Resources: 10 | IAMRole: 11 | Type: AWS::IAM::Role 12 | Properties: 13 | RoleName: !FindInMap [ VariablesMap, TrustedAccount, RoleName] 14 | AssumeRolePolicyDocument: 15 | Version: 2012-10-17 16 | Statement: 17 | - Effect: Allow 18 | Action: sts:AssumeRole 19 | Principal: 20 | AWS: !Join [ ':', [ 'arn:aws:iam:', !FindInMap [ VariablesMap, TrustedAccount, ExternalAccountId], root]] 21 | Service: lambda.amazonaws.com 22 | IAMPolicy: 23 | Type: AWS::IAM::ManagedPolicy 24 | Properties: 25 | Description: Policy granting AWS Organization read permissions 26 | PolicyDocument: 27 | Version: 2012-10-17 28 | Statement: 29 | - Effect: Allow 30 | Action: 31 | - organizations:Describe* 32 | - organizations:List* 33 | Resource: '*' 34 | Roles: 35 | - !Ref 'IAMRole' 36 | Outputs: 37 | AssumeRoleArn: 38 | Value: !GetAtt 'IAMRole.Arn' 39 | Description: The ARN of the IAM Role 40 | -------------------------------------------------------------------------------- /docs/plan.md: -------------------------------------------------------------------------------- 1 | # Deployment Plan 2 | 3 | ## Setup new member IAM role 4 | 5 | * Update the security audit/incident response CloudFormation template to add 6 | another role 7 | * GuardDutyInvitationAcceptor 8 | * guardduty:ListDetectors 9 | * guardduty:CreateDetector 10 | * guardduty:AcceptInvitation 11 | * guardduty:GetDetector 12 | * guardduty:GetInvitationsCount 13 | * guardduty:GetMasterAccount 14 | * guardduty:UpdateDetector 15 | 16 | ## Setup GuardDuty master in all regions 17 | 18 | * Deploy GuardDuty master accounts in infosec-prod all regions 19 | * guardduty:CreateDetector in each region to create a local infosec-prod detector 20 | 21 | ## Create lambda GuardDuty account linker 22 | 23 | * Create Lambda function in infosec-prod which wakes up each night, 3 times in a 24 | row with 5 minutes between each run via CloudWatch and 25 | * Assume role 329567179436:Infosec-Organization-Reader 26 | * Fetch list of accounts and email addresses : organizations:ListAccounts 27 | * Iterate over each GuardDuty region 28 | * Get list of existing GuardDuty member accounts guardduty:ListMembers 29 | for this region 30 | * Iterate over list from organizations:ListAccounts, excluding any accounts 31 | that were returned from guardduty:ListMembers with RelationshipStatus of 32 | 'ENABLED' 33 | * Assume GuardDutyInvitationAcceptor IAM role (or reuse a previously 34 | assumed role during a different region iteration) in each target account 35 | * guardduty:ListDetectors to see if there's an existing detector 36 | * guardduty:CreateDetector to create a new one 37 | * record the detector_id from the listing or the creation 38 | * guardduty:CreateMembers passing it a list of every GuardDuty 39 | account which did not show up in the results of guardduty:ListMembers but 40 | which is in the Organization 41 | * Note : At this point any newly created members will be processing and 42 | we'll skip over them this run 43 | * Note : Here we'll deal with newly created members from the last run 44 | * For each member account that has a RelationshipStatus of 'CREATED' 45 | * guardduty:InviteMembers passing the list of all CREATED members 46 | * Note : At this point the invitations will take time to propagate and those 47 | accounts will be picked up in the next run 48 | * Note : Here we'll deal with newly invited members from the last run 49 | * For each member account that has a RelationshipStatus of 'INVITED' 50 | * Reuse previously assumed GuardDutyInvitationAcceptor IAM role for the 51 | account 52 | * For each invite in guardduty:ListInvitations with an accountId of 53 | infosec-prod 54 | * guardduty:AcceptInvitation 55 | * (not using the assumed role) emit an SNS event reporting that 56 | the invite was accepted 57 | 58 | # Outcome 59 | 60 | * Any new accounts created in the Organization will 61 | * have detectors created in every region 62 | * be created as a member in the infosec-prod GuardDuty master in every region 63 | * be invited to be a member of the infosec-prod GuardDuty master in every region 64 | * accept the invitation to be a member in every region 65 | * emit SNS notifications for each accepted invite 66 | 67 | # The multiple executions of the Lambda function 68 | 69 | * Execution 1 will 70 | * create detectors in every region for every new account 71 | * create the new account as a member in the infosec-prod GuardDuty master in every region 72 | * Execution 2 will 73 | * invite the newly created member to connect to the master 74 | * Execution 3 will 75 | * accept the invitation to connect to the master 76 | 77 | -------------------------------------------------------------------------------- /lambda_functions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/guardduty-multi-account-manager/15b20e6993f428a51ba66b94e11a891075c70620/lambda_functions/__init__.py -------------------------------------------------------------------------------- /lambda_functions/invitation_manager.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import logging 3 | import os 4 | from boto3.dynamodb.types import TypeDeserializer 5 | from boto3.dynamodb.transform import TransformationInjector 6 | 7 | root = logging.getLogger() 8 | if root.handlers: 9 | for handler in root.handlers: 10 | root.removeHandler(handler) 11 | 12 | logging.basicConfig( 13 | level=logging.INFO, 14 | handlers=[logging.StreamHandler()] 15 | ) 16 | logger = logging.getLogger(__name__) 17 | logging.getLogger('botocore').setLevel(logging.CRITICAL) 18 | logging.getLogger('urllib3').setLevel(logging.CRITICAL) 19 | 20 | DYNAMODB_TABLE_NAME = os.environ.get( 21 | 'DYNAMODB_TABLE_NAME', 'cloudformation-stack-emissions') 22 | DB_CATEGORY = os.environ.get( 23 | 'DB_CATEGORY', 'GuardDuty Multi Account Member Role') 24 | ORGANIZATION_IAM_ROLE_ARNS = os.environ.get( 25 | 'ORGANIZATION_IAM_ROLE_ARNS') 26 | ACCOUNT_FILTER_LIST = os.environ.get('ACCOUNT_FILTER_LIST', '') 27 | 28 | 29 | class GetMembers: 30 | """Return a function which allows for filtering the member dict""" 31 | 32 | def __init__(self, all_members): 33 | self.all_members = all_members 34 | 35 | def __call__(self, *args): 36 | """Return the account IDs of accounts with one of the passed 37 | relationship statuses 38 | 39 | CREATED : Member created by master but master hasn't invited member 40 | INVITED : Member invited by master with DisableEmailNotification=True 41 | DISABLED : Member has accepted invitation but detector has been updated 42 | to Enable=False 43 | ENABLED : Member has accepted invitation 44 | REMOVED : Member has accepted invitation but detector has been deleted 45 | RESIGNED : Member that had accepted the invitation, then later called 46 | DisassociateFromMasterAccount or deselected the "Accept" 47 | button in the web console 48 | EMAILVERIFICATIONINPROGRESS : Member invited by master with 49 | DisableEmailNotification=False 50 | EMAILVERIFICATIONFAILED : 51 | Not present : Member's never been created or member has resigned, and 52 | then clicked the `x` in the web console to delete 53 | themselves 54 | 55 | :param args: Relationship status 56 | :return: List of account IDs 57 | """ 58 | return [k for k, v in self.all_members.items() 59 | if v.lower() in [x.lower() for x in args]] 60 | 61 | 62 | def get_session(role_arn=None): 63 | """Return a boto session either for the current IAM Role or for an assumed 64 | role if role_arn is passed 65 | 66 | :param role_arn: An ARN of an AWS IAM role to assume 67 | :return: Boto session 68 | """ 69 | if role_arn is not None: 70 | client = boto3.client('sts') 71 | try: 72 | credentials = client.assume_role( 73 | RoleArn=role_arn, 74 | RoleSessionName='GuardDutyMultiAccountManager', 75 | DurationSeconds=900 76 | )['Credentials'] 77 | boto_session = boto3.session.Session( 78 | aws_access_key_id=credentials['AccessKeyId'], 79 | aws_secret_access_key=credentials['SecretAccessKey'], 80 | aws_session_token=credentials['SessionToken'] 81 | ) 82 | except: 83 | logging.error('Failed to assume role %s' % role_arn) 84 | raise 85 | else: 86 | boto_session = boto3.session.Session() 87 | return boto_session 88 | 89 | 90 | def create_detector(boto_session, region_name, account_id=''): 91 | gd = boto_session.client('guardduty', region_name=region_name) 92 | response = gd.create_detector( 93 | Enable=True, 94 | # FindingPublishingFrequency='FIFTEEN_MINUTES' 95 | # We need a newer version of boto3 to support this argument 96 | # https://github.com/boto/botocore/commit/31f3dfd37a89b018f818807d9977d6d4e5090467 97 | ) 98 | logger.info( 99 | '{} : {} : Created detector {}'.format( 100 | region_name, account_id, response['DetectorId'])) 101 | return response 102 | 103 | 104 | def get_all_detectors(boto_session, region_name): 105 | gd = boto_session.client('guardduty', region_name=region_name) 106 | response = gd.list_detectors() 107 | return response 108 | 109 | 110 | def find_or_create_detector(boto_session, region_name, account_id): 111 | resp = get_all_detectors(boto_session, region_name) 112 | if len(resp['DetectorIds']) > 0: 113 | return resp['DetectorIds'][0] 114 | else: 115 | resp = create_detector(boto_session, region_name, account_id) 116 | return resp['DetectorId'] 117 | 118 | 119 | def get_account_id_email_map_from_organizations(boto_session, region_name): 120 | """List AWS Organization child accounts and return a map of account IDs to 121 | account email addresses. 122 | 123 | :param boto_session: Boto session 124 | :param region_name: AWS region name 125 | :return: dict with account ID keys and email address values 126 | """ 127 | client = boto_session.client('organizations', region_name=region_name) 128 | paginator = client.get_paginator('list_accounts') 129 | accounts = [] 130 | list(map(accounts.extend, [x['Accounts'] for x in paginator.paginate()])) 131 | account_map = {x['Id']: x['Email'] for x in accounts} 132 | return account_map 133 | 134 | 135 | def get_account_role_map(boto_session, region_name): 136 | """Fetch the ARNs of all the IAM Roles which people have created in other 137 | AWS accounts which are inserted into DynamoDB with 138 | https://github.com/mozilla/cloudformation-cross-account-outputs 139 | 140 | :return: dict with account ID keys and IAM Role ARN values 141 | """ 142 | 143 | client = boto_session.client('dynamodb', region_name=region_name) 144 | 145 | paginator = client.get_paginator('scan') 146 | service_model = client._service_model.operation_model('Scan') 147 | trans = TransformationInjector(deserializer=TypeDeserializer()) 148 | items = [] 149 | for page in paginator.paginate(TableName=DYNAMODB_TABLE_NAME): 150 | trans.inject_attribute_value_output(page, service_model) 151 | items.extend([x for x in page['Items']]) 152 | 153 | return {x['aws-account-id']: x['GuardDutyMemberAccountIAMRoleArn'] 154 | for x in items 155 | if x.get('category') == DB_CATEGORY 156 | and {'aws-account-id', 157 | 'GuardDutyMemberAccountIAMRoleArn'} <= set(x)} 158 | 159 | 160 | def tear_down_members(account_ids): 161 | local_boto_session = get_session(os.environ.get('MANAGER_IAM_ROLE_ARN')) 162 | local_account_id = boto3.client('sts').get_caller_identity()["Account"] 163 | guardduty_regions = local_boto_session.get_available_regions('guardduty') 164 | account_id_role_arn_map = get_account_role_map( 165 | local_boto_session, 'us-west-2') 166 | for region_name in guardduty_regions: 167 | detector_id = get_all_detectors( 168 | local_boto_session, region_name)['DetectorIds'][0] 169 | client = local_boto_session.client( 170 | 'guardduty', region_name=region_name) 171 | client.delete_members( 172 | AccountIds=account_ids, 173 | DetectorId=detector_id 174 | ) 175 | logger.info('{}: Deleted member {} from master detector {}'.format( 176 | region_name, account_ids, detector_id)) 177 | 178 | for account_id in account_ids: 179 | member_boto_session = get_session( 180 | account_id_role_arn_map[account_id]) 181 | member_client = member_boto_session.client( 182 | 'guardduty', region_name=region_name) 183 | for member_detector_id in get_all_detectors( 184 | member_boto_session, region_name)['DetectorIds']: 185 | try: 186 | member_client.disassociate_from_master_account( 187 | DetectorId=member_detector_id) 188 | logger.info( 189 | '{}: {} : Dissasociated member dector id {} from ' 190 | 'master'.format( 191 | region_name, account_id, member_detector_id)) 192 | except: 193 | pass 194 | try: 195 | member_client.delete_detector( 196 | DetectorId=member_detector_id) 197 | logger.info( 198 | '{}: {} : Deleted member detector id {}'.format( 199 | region_name, account_id, member_detector_id)) 200 | except: 201 | pass 202 | member_client.delete_invitations(AccountIds=[local_account_id]) 203 | logger.info( 204 | '{}: {} : Invitation from {} deleted'.format( 205 | region_name, account_id, local_account_id)) 206 | 207 | 208 | def handle(event, context): 209 | """Move all AWS accounts in an AWS Organization which have delegated 210 | permissions to this account towards a functioning member master 211 | GuardDuty relationship. 212 | 213 | * Fetch the accounts list from AWS Organizations 214 | * Get IAM Role ARNs for each account 215 | * For each region 216 | * Ensure that a GuardDuty master detector is created 217 | * Fetch the GuardDuty members list 218 | * Create members for accounts that haven't been created yet 219 | * Invite members that have been created 220 | * For each account 221 | * Update member account detector to enabled if DISABLED 222 | * Get or create a detector in the member account 223 | * For members with a pending invitation, accept the invitation in the 224 | member account 225 | 226 | Set environment variables 227 | * ORGANIZATION_IAM_ROLE_ARN_LIST : Comma delimited list of IAM Role ARNs 228 | to assume to reach AWS Organization parent accounts 229 | * ACCOUNT_FILTER_LIST : Space delimited list of account IDs to include. 230 | If this is provided, only these accounts will be included. If it's not 231 | provided, all accounts will be included. 232 | 233 | :param event: Lambda event object 234 | :param context: Lambda context object 235 | """ 236 | local_boto_session = get_session(os.environ.get('MANAGER_IAM_ROLE_ARN')) 237 | local_account_id = boto3.client('sts').get_caller_identity()["Account"] 238 | guardduty_regions = local_boto_session.get_available_regions('guardduty') 239 | default_region = 'us-west-2' 240 | organizations_account_id_map = {} 241 | org_arn_list = ( 242 | [x.strip() for x in ORGANIZATION_IAM_ROLE_ARNS.split(',')] 243 | if ORGANIZATION_IAM_ROLE_ARNS is not None else [None]) 244 | for org_arn in org_arn_list: 245 | org_boto_session = get_session(org_arn) 246 | 247 | # Fetch the accounts list from AWS Organizations 248 | organizations_account_id_map.update( 249 | get_account_id_email_map_from_organizations( 250 | org_boto_session, region_name=default_region)) 251 | 252 | logger.debug( 253 | 'Organization account ID map: {}'.format(organizations_account_id_map)) 254 | 255 | # Filter accounts to only those in ACCOUNT_FILTER_LIST 256 | if ACCOUNT_FILTER_LIST: 257 | organizations_account_id_map = { 258 | k: v for k, v in organizations_account_id_map.items() 259 | if k in ACCOUNT_FILTER_LIST.split()} 260 | logger.debug( 261 | 'Filtered organization account ID map: {}'.format( 262 | organizations_account_id_map)) 263 | 264 | # Get IAM Role ARNs for each account 265 | account_id_role_arn_map = get_account_role_map( 266 | local_boto_session, default_region) 267 | logger.debug( 268 | 'Account ID IAM Role map: {}'.format(account_id_role_arn_map)) 269 | 270 | for region_name in guardduty_regions: 271 | # Ensure that a GuardDuty master detector is created 272 | local_detector_id = find_or_create_detector( 273 | local_boto_session, region_name, local_account_id) 274 | 275 | # Fetch the GuardDuty members list 276 | client = local_boto_session.client( 277 | 'guardduty', region_name=region_name) 278 | list_of_members = [ 279 | y for sublist in [ 280 | x['Members'] for x in client.get_paginator( 281 | 'list_members').paginate( 282 | DetectorId=local_detector_id, OnlyAssociated="FALSE")] 283 | for y in sublist] 284 | members = {x['AccountId']: x['RelationshipStatus'] 285 | for x in list_of_members} 286 | logger.debug('{} : Member dict : {}'.format(region_name, members)) 287 | 288 | # Create a get_members function to work with the members list 289 | get_members = GetMembers(members) 290 | 291 | # Create members for accounts that haven't been created yet 292 | account_details = [ 293 | {'AccountId': account_id, 'Email': email} 294 | for account_id, email in organizations_account_id_map.items() 295 | if account_id in account_id_role_arn_map.keys() and 296 | (account_id not in members 297 | or account_id in get_members('REMOVED'))] 298 | if account_details: 299 | client.create_members( 300 | AccountDetails=account_details, 301 | DetectorId=local_detector_id) 302 | logger.info( 303 | '{} : Members created : {}'.format( 304 | region_name, account_details)) 305 | 306 | # Delete members that got stuck at email verification 307 | account_ids_to_delete = get_members('EMAILVERIFICATIONFAILED') 308 | if account_ids_to_delete: 309 | client.delete_members( 310 | AccountIds=account_ids_to_delete, 311 | DetectorId=local_detector_id) 312 | logger.info('{} : Member deleted due to email verification failure' 313 | ' : {}'.format(region_name, account_ids_to_delete)) 314 | 315 | # Invite members that have been created 316 | account_ids_to_invite = get_members('CREATED', 'RESIGNED') 317 | if account_ids_to_invite: 318 | client.invite_members( 319 | AccountIds=account_ids_to_invite, 320 | DetectorId=local_detector_id, 321 | DisableEmailNotification=True) 322 | logger.info( 323 | '{} : Member invited : {}'.format( 324 | region_name, account_ids_to_invite)) 325 | 326 | for account_id in (set(organizations_account_id_map.keys()) 327 | & set(account_id_role_arn_map.keys())): 328 | boto_session = get_session(account_id_role_arn_map[account_id]) 329 | member_client = boto_session.client( 330 | 'guardduty', region_name=region_name) 331 | if account_id in get_members('DISABLED'): 332 | # For DISABLED members 333 | # Update member account detector to enabled 334 | 335 | detector_id = find_or_create_detector( 336 | boto_session, region_name, account_id) 337 | member_client.update_detector( 338 | DetectorId=detector_id, 339 | Enable=True) 340 | logger.info( 341 | '{} : {} : Member updated to re-enable detector'.format( 342 | region_name, account_id)) 343 | if account_id in get_members( 344 | 'RESIGNED', 'REMOVED', 'INVITED', 345 | 'EMAILVERIFICATIONINPROGRESS'): 346 | # Get or create a detector in the member account 347 | detector_id = find_or_create_detector( 348 | boto_session, region_name, account_id) 349 | if account_id in get_members( 350 | 'RESIGNED', 'INVITED', 'EMAILVERIFICATIONINPROGRESS'): 351 | # For members with a pending invitation 352 | # Accept the invitation in the member account 353 | response = member_client.list_invitations() 354 | invitation_id = next(( 355 | x['InvitationId'] for x in response['Invitations'] 356 | if x['AccountId'] == local_account_id), None) 357 | if invitation_id is not None: 358 | member_client.accept_invitation( 359 | DetectorId=detector_id, 360 | InvitationId=invitation_id, 361 | MasterId=local_account_id) 362 | logger.info('{} : {} : Accepted member invite on their' 363 | ' behalf'.format(region_name, account_id)) 364 | else: 365 | logger.error( 366 | '{} : {} : GuardDuty parent reports member ' 367 | 'RelationshipStatus of {} however member reports ' 368 | 'pending invitations of {}'.format( 369 | region_name, account_id, members[account_id], 370 | response['Invitations'])) 371 | -------------------------------------------------------------------------------- /lambda_functions/normalization.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import json 3 | import logging 4 | 5 | from datetime import datetime 6 | from os import getenv 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | SNS_OUTPUT_TOPIC_ARN = getenv('SNS_OUTPUT_TOPIC_ARN') 11 | 12 | def convert_my_iso_8601(iso_8601): 13 | assert iso_8601[-1] == 'Z' 14 | iso_8601 = iso_8601[:-1] + '000' 15 | iso_8601_dt = datetime.strptime(iso_8601, '%Y-%m-%dT%H:%M:%S.%f') 16 | return str(iso_8601_dt) 17 | 18 | def send_to_sns(event, sns_client): 19 | """Send the transformed message to the SNS topic that outputs to another SQS queue.""" 20 | return sns_client.publish( 21 | TopicArn=SNS_OUTPUT_TOPIC_ARN, 22 | Message=json.dumps(event) 23 | ) 24 | 25 | def _get_resource_info(guardduty_event): 26 | resource = guardduty_event['detail'].get('resource', {}) 27 | instance_detail = resource.get('instanceDetails', None) 28 | if instance_detail is not None: 29 | return instance_detail.get('instanceId') 30 | else: 31 | return 'guardduty-{account_id}'.format(account_id=guardduty_event.get('account')) 32 | 33 | def transform_event(event): 34 | """Take guardDuty SNS notification and turn it into a standard MozDef event.""" 35 | guardduty_event = json.loads(event['Sns']['Message']) 36 | 37 | mozdef_event = { 38 | 'timestamp': convert_my_iso_8601(event['Sns'].get('Timestamp')), 39 | 'hostname': _get_resource_info(guardduty_event), 40 | 'processname': 'guardduty', 41 | 'processid': 1337, 42 | 'severity': 'INFO', 43 | 'summary': guardduty_event['detail']['description'], 44 | 'category': guardduty_event['detail']['type'], 45 | 'source': 'guardduty', 46 | 'tags': [ 47 | guardduty_event['detail']['service']['action']['actionType'] 48 | ], 49 | 'details': guardduty_event.get('detail') 50 | } 51 | 52 | # there is only one 'service', guard duty 53 | # rename details.service to details.finding 54 | # to make it more descriptive and match aws docs 55 | # and avoid schema collisions 56 | mozdef_event['details']['finding']=mozdef_event['details'].pop('service') 57 | 58 | return mozdef_event 59 | 60 | def handle(event, context): 61 | """Basic lambda handler.""" 62 | sns_client = boto3.client('sns') 63 | for record in event.get('Records', []): 64 | try: 65 | mozdef_event = transform_event(record) 66 | res = send_to_sns(mozdef_event, sns_client) 67 | except Exception as e: 68 | logger.error('Received exception "{}" for event {}'.format(e, record)) 69 | raise 70 | return mozdef_event 71 | -------------------------------------------------------------------------------- /lambda_functions/plumbing.py: -------------------------------------------------------------------------------- 1 | """Create SNS topics and cloudwatch events in each region. 2 | Ensure continued publishing to 3 | """ 4 | import boto3 5 | import json 6 | import os 7 | 8 | from botocore.exceptions import ClientError 9 | 10 | from logging import basicConfig 11 | from logging import getLogger 12 | from logging import INFO 13 | from logging import StreamHandler 14 | 15 | 16 | logger = getLogger(__name__) 17 | basicConfig( 18 | level=INFO, 19 | handlers=[StreamHandler()] 20 | ) 21 | 22 | 23 | EVENT_PATTERN = { 24 | "source": [ 25 | "aws.guardduty" 26 | ], 27 | "detail-type": [ 28 | "GuardDuty Finding" 29 | ] 30 | } 31 | 32 | 33 | NORMALIZER_LAMBDA_FUNCTION = os.getenv( 34 | 'NORMALIZER_LAMBDA_FUNCTION', 35 | 'arn:aws:lambda:us-east-1:371522382791:function:findingsToMozDef-1O04GFRLK0EJQ' 36 | ) 37 | 38 | 39 | def get_topics(boto_session): 40 | """Return the list of SNS topics in a given region.""" 41 | client = boto_session.client('sns') 42 | response = client.list_topics() 43 | if len(response['Topics']) == 0: 44 | return [] 45 | else: 46 | return response['Topics'] 47 | 48 | 49 | def find_or_create_sns_topic(boto_session): 50 | """Search for the mozilla-gd-plumbing topic and return the arn. If the topic does not exist create it.""" 51 | client = boto_session.client('sns') 52 | topics = get_topics(boto_session) 53 | if len(topics) == 0: 54 | response = client.create_topic( 55 | Name='mozilla-gd-plumbing' 56 | ) 57 | else: 58 | for topic in get_topics(boto_session): 59 | if topic['TopicArn'].endswith('mozilla-gd-plumbing') : 60 | return topic['TopicArn'] 61 | 62 | else: 63 | response = client.create_topic( 64 | Name='mozilla-gd-plumbing' 65 | ) 66 | 67 | return response['TopicArn'] 68 | 69 | 70 | def clean_subscription_list(boto_session): 71 | """Search for the mozilla-gd-plumbing topic and return the arn. If the topic does not exist create it.""" 72 | client = boto_session.client('sns') 73 | response = client.list_subscriptions_by_topic( 74 | TopicArn=find_or_create_sns_topic(boto_session) 75 | ) 76 | if len(response['Subscriptions']) == 0: 77 | pass 78 | else: 79 | for i in response['Subscriptions']: 80 | if NORMALIZER_LAMBDA_FUNCTION != i.get('Endpoint', ''): 81 | logger.info('Unsubcribing lambda from topic. Perhaps you re-deployed?') 82 | response = client.unsubscribe( 83 | SubscriptionArn=i['SubscriptionArn'] 84 | ) 85 | 86 | def topic_is_subscribed(boto_session): 87 | """Enumerate the list of subscriptions for a given topic arn and test to see if it contains the normalizer.""" 88 | client = boto_session.client('sns') 89 | response = client.list_subscriptions_by_topic( 90 | TopicArn=find_or_create_sns_topic(boto_session) 91 | ) 92 | 93 | if len(response['Subscriptions']) == 0: 94 | return False 95 | else: 96 | for i in response['Subscriptions']: 97 | if NORMALIZER_LAMBDA_FUNCTION == i.get('Endpoint', ''): 98 | return True 99 | return False 100 | 101 | 102 | def subscribe_to_normalization_function(boto_session): 103 | """Add a subscription to the current topic for the lambda function that does data transformation.""" 104 | client = boto_session.client('sns') 105 | response = client.subscribe( 106 | TopicArn=find_or_create_sns_topic(boto_session), 107 | Protocol='lambda', 108 | Endpoint=NORMALIZER_LAMBDA_FUNCTION, 109 | ) 110 | return response 111 | 112 | 113 | def ensure_topic_subscriptions(boto_session): 114 | """Ensure the sns topic is subscribed to the normalization lambda.""" 115 | if topic_is_subscribed(boto_session): 116 | pass 117 | else: 118 | subscribe_to_normalization_function(boto_session) 119 | 120 | 121 | def get_all_rules(boto_session): 122 | """Search for the mozilla-gd-plumbing rule only. Returns a list of one.""" 123 | client = boto_session.client('events') 124 | response = client.list_rules(NamePrefix='mozilla-gd-plumbing') 125 | return response['Rules'] 126 | 127 | 128 | def setup_guardduty_plumbing(boto_session): 129 | """Create the cloudwatch event rule.""" 130 | client = boto_session.client('events') 131 | response = client.put_rule( 132 | Name='mozilla-gd-plumbing', 133 | EventPattern=json.dumps(EVENT_PATTERN), 134 | State='ENABLED', 135 | Description='Send all guardDuty findings to SNS for SIEM normalization.', 136 | ) 137 | return response 138 | 139 | 140 | def setup_sns_publishing(boto_session): 141 | """Add teh sns topic in a given region to the cloudwatch event rule.""" 142 | client = boto_session.client('events') 143 | response = client.put_targets( 144 | Rule='mozilla-gd-plumbing', 145 | Targets=[ 146 | { 147 | 'Arn': find_or_create_sns_topic(boto_session), 148 | 'Id': 'normalizationSNS', 149 | } 150 | ] 151 | ) 152 | return response 153 | 154 | 155 | def get_all_aws_regions(boto_session): 156 | return boto_session.get_available_regions('guardduty') 157 | 158 | def add_lambda_permission(region_session, region_name): 159 | boto_session = boto3.session.Session(region_name='us-east-1') 160 | client = boto_session.client('lambda') 161 | try: 162 | response = client.add_permission( 163 | Action='lambda:InvokeFunction', 164 | FunctionName=NORMALIZER_LAMBDA_FUNCTION.split(':')[6], 165 | Principal='sns.amazonaws.com', 166 | SourceArn=find_or_create_sns_topic(region_session), 167 | StatementId='{}-sns-invoke'.format(region_name) 168 | ) 169 | return response 170 | except ClientError as e: 171 | logger.debug( 172 | 'Could not creake invoke permission. Permission already exists in region: {}'.format(e, region_name) 173 | ) 174 | 175 | 176 | 177 | def handle(event=None, context=None): 178 | logger.info('Activating guardDuty plumbing.') 179 | boto_session = boto3.session.Session() 180 | for region in get_all_aws_regions(boto_session): 181 | logger.info('Attempting cross region setup for {}.'.format(region)) 182 | region_session = boto3.session.Session(region_name=region) 183 | logger.info('Ensuring guardduty cloudwatch event exists for {}.'.format(region)) 184 | setup_guardduty_plumbing(region_session) 185 | logger.info('Ensuring guardduty sns topic exists for {}.'.format(region)) 186 | setup_sns_publishing(region_session) 187 | logger.info( 188 | 'Ensuring guardduty sns topic is subscribed to the normalization function for {}.'.format( 189 | region 190 | ) 191 | ) 192 | ensure_topic_subscriptions(region_session) 193 | clean_subscription_list(region_session) 194 | add_lambda_permission(region_session, region) 195 | logger.info('Run complete for region: {}'.format(region)) 196 | 197 | 198 | if __name__ == "__main__": 199 | handle() 200 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cfnlint 2 | pytest 3 | pytest-watch 4 | pytest-cov 5 | boto3 6 | -------------------------------------------------------------------------------- /tests/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/guardduty-multi-account-manager/15b20e6993f428a51ba66b94e11a891075c70620/tests/.keep --------------------------------------------------------------------------------