├── lambda ├── Makefile └── enable_guardduty.py ├── scripts ├── delete_splunk_stack_in_all_regions.sh ├── deploy_splunk_to_all_regions.sh └── enable_guardduty.py ├── cloudformation ├── GuardDuty2Splunk-SAM-Template.yaml ├── GuardDuty-Enable-Template.yaml └── GuardDuty2Splunk-Template.yaml ├── README.md ├── .gitignore ├── Makefile └── LICENSE /lambda/Makefile: -------------------------------------------------------------------------------- 1 | PYTHON=python3 2 | PIP=pip3 3 | 4 | FILES=enable_guardduty.py 5 | 6 | package: test clean zipfile 7 | 8 | test: $(FILES) 9 | for f in $^; do $(PYTHON) -m py_compile $$f; if [ $$? -ne 0 ] ; then echo "$$f FAILS" ; exit 1; fi done 10 | 11 | clean: 12 | rm -rf __pycache__ *.zip *.dist-info $(DEPENDENCIES) 13 | 14 | # Create the package Zip. Assumes all tests were done 15 | zipfile: $(FILES) 16 | zip -r $(LAMBDA_PACKAGE) $^ 17 | -------------------------------------------------------------------------------- /scripts/delete_splunk_stack_in_all_regions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # All stacks will be named "${STACK_NAME}-${region}" 4 | STACK_NAME="GuardDuty2Splunk" 5 | 6 | # Get a list of the active AWS Regions for the account 7 | REGIONS=`aws ec2 describe-regions --query "Regions[].RegionName" --output text` 8 | 9 | for r in $REGIONS ; do 10 | echo "Deleting GuardDuty To Splunk CFT in $r" 11 | aws cloudformation update-termination-protection --region $r --stack-name "${STACK_NAME}-${r}" --no-enable-termination-protection 12 | aws cloudformation delete-stack --region $r --stack-name "${STACK_NAME}-${r}" 13 | done -------------------------------------------------------------------------------- /scripts/deploy_splunk_to_all_regions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # All stacks will be named "${STACK_NAME}-${region}" 4 | STACK_NAME="GuardDuty2Splunk" 5 | 6 | # Name of Secrets Manager secret 7 | SECRET_NAME=GuardDutyHEC 8 | SECRET_REGION=us-east-1 9 | 10 | 11 | 12 | echo "Using ${SECRET_NAME} in ${SECRET_REGION} as the HEC Endpoint and Token" 13 | 14 | # Get a list of the active AWS Regions for the account 15 | REGIONS=`aws ec2 describe-regions --query "Regions[].RegionName" --output text` 16 | 17 | for r in $REGIONS ; do 18 | echo "Deploying GuardDuty To Splunk CFT in $r" 19 | echo -n "New Stack ID: " 20 | aws cloudformation create-stack --region $r --stack-name "${STACK_NAME}-${r}" \ 21 | --template-body file://cloudformation/GuardDuty2Splunk-Template.yaml \ 22 | --parameters ParameterKey=pHECSecretName,ParameterValue=${SECRET_NAME} ParameterKey=pHECSecretRegion,ParameterValue=${SECRET_REGION} \ 23 | --capabilities "CAPABILITY_IAM" \ 24 | --enable-termination-protection --output text && \ 25 | aws cloudformation wait stack-create-complete --region $r --stack-name "${STACK_NAME}-${r}" 26 | if [ $? -ne 0 ] ; then 27 | echo "WARNING! Failed to Deploy in $r" 28 | else 29 | echo "Successfuly deployed to $r" 30 | fi 31 | done -------------------------------------------------------------------------------- /cloudformation/GuardDuty2Splunk-SAM-Template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: Deploy a GuardDuty CloudWatchEvent and SAM Application for sending GuardDuty Events to Splunk HEC 3 | Transform: AWS::Serverless-2016-10-31 4 | 5 | Parameters: 6 | SplunkHttpEventCollectorURL: 7 | Type: String 8 | Description: URL address of your Splunk HTTP event collector endpoint 9 | 10 | SplunkHttpEventCollectorToken: 11 | Type: String 12 | Description: Token of your Splunk HTTP event collector endpoint 13 | 14 | Resources: 15 | 16 | SplunkToHECLambda: 17 | Type: AWS::Serverless::Application 18 | Properties: 19 | Location: 20 | ApplicationId: arn:aws:serverlessrepo:us-east-1:xxx:applications/splunk-json-logging 21 | SemanticVersion: 0.0.1 22 | Parameters: 23 | # Token of your Splunk HTTP event collector endpoint 24 | SplunkHttpEventCollectorToken: !Ref SplunkHttpEventCollectorToken 25 | # URL address of your Splunk HTTP event collector endpoint 26 | SplunkHttpEventCollectorURL: !Ref SplunkHttpEventCollectorURL 27 | 28 | GuardDutyCloudWatchEvent: 29 | Type: AWS::Events::Rule 30 | Properties: 31 | Description: GuardDutyRuleForSplunk 32 | State: ENABLED 33 | EventPattern: 34 | source: 35 | - aws.guardduty 36 | Targets: 37 | - Arn: !GetAtt SplunkToHECLambda.Outputs.SplunkLoggingFunction 38 | Id: GuardDutyFunction 39 | 40 | LambdaInvokePermission: 41 | Type: AWS::Lambda::Permission 42 | Properties: 43 | Action: lambda:InvokeFunction 44 | Principal: events.amazonaws.com 45 | FunctionName: !GetAtt SplunkToHECLambda.Outputs.SplunkLoggingFunction 46 | SourceArn: !GetAtt GuardDutyCloudWatchEvent.Arn 47 | 48 | Outputs: 49 | SplunkLoggingFunction: 50 | Description: Splunk Logging Lambda Function ARN 51 | Value: !GetAtt SplunkToHECLambda.Outputs.SplunkLoggingFunction -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-guardduty-enterprise 2 | Manage GuardDuty At Enterprise Scale 3 | 4 | 5 | ## What this repo does 6 | 1. Deploy a lambda to enable GuardDuty for new accounts. 7 | 2. Deploy a Lambda to take GuardDuty CloudWatch Events and forward to an Splunk HTTP Event Collector (HEC) of your choice 8 | 9 | More stuff to come later. Like Splunk forwarding, or Security Hub. Maybe.... 10 | 11 | 12 | ## Deployment of the GuardDuty Enable (and Master/Member invitation) Lambda 13 | 14 | 1. Install cfn-deploy 15 | ```bash 16 | pip3 install cftdeploy 17 | ``` 18 | 2. Make the Manifest 19 | ```bash 20 | make BUCKET=SETME enable-manifest 21 | ``` 22 | 3. Edit the Manifest 23 | 1. Remove the lines for pLambdaZipFile and pDeployBucket as they will be set by the Makefile 24 | 2. Add the role name for listing accounts in the payer (pAuditRole) and for accepting the invite in the child (pAcceptRole) 25 | 3. Add a SES emailed email address for the pEmailFrom and pEmailTo parameters 26 | 3. Replace None with the new account topic if you want to subscribe the lambda to a new account topic 27 | 4. Validate the manifest 28 | ```bash 29 | make BUCKET=SETME enable-validate-manifest 30 | ``` 31 | 5. Deploy! 32 | ```bash 33 | make BUCKET=SETME enable-deploy 34 | ``` 35 | 36 | 37 | ## Deployment of the GuardDuty To Splunk Lambdas 38 | 39 | 1. Create A Secret in AWS Secrets Manager. By Default the Secret is named `GuardDutyHEC` and located in `us-east-1`. The format of the secret should be: 40 | ```json 41 | { 42 | "HECToken": "2SOMETHING-THAT-SHOULD-BE-SECRET", 43 | "HECEndpoint": "https://hec.endpoint.yourcompany.com:8088/services/collector/event" 44 | } 45 | ``` 46 | 2. Deploy it everywhere via the `deploy_splunk_to_all_regions.sh` script 47 | ```bash 48 | ~/aws-guardduty-enterprise$ ./scripts/deploy_splunk_to_all_regions.sh 49 | ``` 50 | The Script will deploy a CloudFormation Stack in each region named `GuardDuty2Splunk-$region` and wait for a successful deployment before proceeding to the next region. Modify this script if you didn't use the default secret name, secret region, or want to name the Lambda or CFT something else. 51 | 52 | 3. You can remove the stacks in each region with the `./scripts/delete_splunk_stack_in_all_regions.sh` shell script. 53 | 54 | Note: There is no update script at the moment. Sorry..... 55 | 56 | ## Required format for the SNS Message for the Enable Lambda: 57 | The message published to SNS must contain the following element: 58 | ```python 59 | message = { 60 | 'account_id': 'string', 61 | 'dry_run': true|false, # optional, if un-specified, dry_run=false 62 | 'region': ['string'], # optional, if un-specified, runs all regions 63 | } 64 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Generic places I've shoved things from a scratch/perspective 3 | Scratch/ 4 | Notes.md 5 | 6 | # Build-time crud 7 | *.zip 8 | *-test-event.json 9 | *-config.json 10 | *dist-info 11 | 12 | 13 | # Manifests are inputs to Cloudformation. They contain deploy specific things (like payer accounts) 14 | *Manifest.yaml 15 | 16 | # Test events also contain sensitive datad 17 | sample_event/* 18 | 19 | # creds 20 | config.json 21 | webhook.txt 22 | 23 | # Created by https://www.gitignore.io/api/osx,python 24 | 25 | ### OSX ### 26 | *.DS_Store 27 | .AppleDouble 28 | .LSOverride 29 | 30 | # Icon must end with two \r 31 | Icon 32 | 33 | # Thumbnails 34 | ._* 35 | 36 | # Files that might appear in the root of a volume 37 | .DocumentRevisions-V100 38 | .fseventsd 39 | .Spotlight-V100 40 | .TemporaryItems 41 | .Trashes 42 | .VolumeIcon.icns 43 | .com.apple.timemachine.donotpresent 44 | 45 | # Directories potentially created on remote AFP share 46 | .AppleDB 47 | .AppleDesktop 48 | Network Trash Folder 49 | Temporary Items 50 | .apdisk 51 | 52 | ### Python ### 53 | # Byte-compiled / optimized / DLL files 54 | __pycache__/ 55 | *.py[cod] 56 | *$py.class 57 | 58 | # C extensions 59 | *.so 60 | 61 | # Distribution / packaging 62 | .Python 63 | env/ 64 | build/ 65 | develop-eggs/ 66 | dist/ 67 | downloads/ 68 | eggs/ 69 | .eggs/ 70 | # lib/ 71 | lib64/ 72 | parts/ 73 | sdist/ 74 | var/ 75 | wheels/ 76 | *.egg-info/ 77 | .installed.cfg 78 | *.egg 79 | 80 | # PyInstaller 81 | # Usually these files are written by a python script from a template 82 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 83 | *.manifest 84 | *.spec 85 | 86 | # Installer logs 87 | pip-log.txt 88 | pip-delete-this-directory.txt 89 | 90 | # Unit test / coverage reports 91 | htmlcov/ 92 | .tox/ 93 | .coverage 94 | .coverage.* 95 | .cache 96 | nosetests.xml 97 | coverage.xml 98 | *,cover 99 | .hypothesis/ 100 | 101 | # Translations 102 | *.mo 103 | *.pot 104 | 105 | # Django stuff: 106 | *.log 107 | local_settings.py 108 | 109 | # Flask stuff: 110 | instance/ 111 | .webassets-cache 112 | 113 | # Scrapy stuff: 114 | .scrapy 115 | 116 | # Sphinx documentation 117 | docs/_build/ 118 | 119 | # PyBuilder 120 | target/ 121 | 122 | # Jupyter Notebook 123 | .ipynb_checkpoints 124 | 125 | # pyenv 126 | .python-version 127 | 128 | # celery beat schedule file 129 | celerybeat-schedule 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # dotenv 135 | .env 136 | 137 | # virtualenv 138 | .venv 139 | venv/ 140 | ENV/ 141 | 142 | # Spyder project settings 143 | .spyderproject 144 | .spyproject 145 | 146 | # Rope project settings 147 | .ropeproject 148 | 149 | # mkdocs documentation 150 | /site 151 | 152 | 153 | # End of https://www.gitignore.io/api/osx,python 154 | 155 | 156 | -------------------------------------------------------------------------------- /cloudformation/GuardDuty-Enable-Template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: Deploy Lambda and other resources for AWS GuardDuty Management 3 | 4 | Parameters: 5 | 6 | pDeployBucket: 7 | Description: Name of a bucket to store the lambda zip 8 | Type: String 9 | 10 | pLambdaZipFile: 11 | Description: File name for the lambda zip 12 | Type: String 13 | 14 | pAuditRole: 15 | Description: Name of the role the lambda needs to assume into the payer account to describe account 16 | Type: String 17 | 18 | pAcceptRole: 19 | Description: Name of the role the lambda needs to assume to do cross account access and accept GuardDuty invites 20 | Type: String 21 | 22 | pNewAccountTopicArn: 23 | Description: ARN of the Topic the enable GuardDuty Needs to subscribe to 24 | Type: String 25 | Default: None 26 | 27 | pEmailFrom: 28 | Description: SES Enabled email address to send the notifcation email from 29 | Type: String 30 | 31 | pEmailTo: 32 | Description: SES Enabled email address to send the notifcation email to 33 | Type: String 34 | 35 | 36 | Conditions: 37 | Subscribe: !Not [!Equals [ !Ref pNewAccountTopicArn, None ]] 38 | 39 | Resources: 40 | 41 | EnableGuardDutyLambdaRole: 42 | Type: AWS::IAM::Role 43 | Properties: 44 | AssumeRolePolicyDocument: 45 | Version: '2012-10-17' 46 | Statement: 47 | - Effect: Allow 48 | Principal: 49 | Service: 50 | - lambda.amazonaws.com 51 | Action: 52 | - sts:AssumeRole 53 | Path: / 54 | Policies: 55 | - PolicyName: LambdaLogging 56 | PolicyDocument: 57 | Version: '2012-10-17' 58 | Statement: 59 | - Resource: '*' 60 | Action: 61 | - logs:* 62 | Effect: Allow 63 | - PolicyName: AssumeCrossAccountRole 64 | PolicyDocument: 65 | Version: '2012-10-17' 66 | Statement: 67 | - Effect: "Allow" 68 | Action: 69 | - sts:AssumeRole 70 | Resource: !Sub "arn:aws:iam::*:role/${pAcceptRole}" 71 | - PolicyName: AssumePayerAccountRole 72 | PolicyDocument: 73 | Version: '2012-10-17' 74 | Statement: 75 | - Effect: "Allow" 76 | Action: 77 | - sts:AssumeRole 78 | Resource: !Sub "arn:aws:iam::*:role/${pAuditRole}" 79 | - PolicyName: DescribeRegions 80 | PolicyDocument: 81 | Version: '2012-10-17' 82 | Statement: 83 | - Effect: Allow 84 | Action: ec2:DescribeRegions 85 | Resource: '*' 86 | - PolicyName: GuardDutyPermissions 87 | PolicyDocument: 88 | Version: '2012-10-17' 89 | Statement: 90 | - Effect: Allow 91 | Action: 92 | - guardduty:ListDetectors 93 | - guardduty:CreateDetector 94 | - guardduty:ListMembers 95 | - guardduty:CreateMembers 96 | - guardduty:InviteMembers 97 | Resource: '*' 98 | - PolicyName: SendEmails 99 | PolicyDocument: 100 | Version: '2012-10-17' 101 | Statement: 102 | - Effect: Allow 103 | Action: 104 | - ses:* 105 | Resource: '*' 106 | 107 | EnableGuardDutyLambdaFunction: 108 | Type: AWS::Lambda::Function 109 | Properties: 110 | FunctionName: !Sub "${AWS::StackName}-enable-guardduty" 111 | Description: Enable GuardDuty for a given account 112 | Handler: enable_guardduty.handler 113 | Runtime: python3.6 114 | Timeout: 300 115 | MemorySize: 768 116 | Role: !GetAtt EnableGuardDutyLambdaRole.Arn 117 | Code: 118 | S3Bucket: !Ref pDeployBucket 119 | S3Key: !Ref pLambdaZipFile 120 | Environment: 121 | Variables: 122 | ACCEPT_ROLE: !Ref pAcceptRole 123 | AUDIT_ROLE: !Ref pAuditRole 124 | EMAIL_FROM: !Ref pEmailFrom 125 | EMAIL_TO: !Ref pEmailTo 126 | 127 | EnableGuardDutyLambdaFunctionPermission: 128 | Type: AWS::Lambda::Permission 129 | Condition: Subscribe 130 | Properties: 131 | FunctionName: !GetAtt EnableGuardDutyLambdaFunction.Arn 132 | Principal: sns.amazonaws.com 133 | Action: lambda:invokeFunction 134 | 135 | EnableGuardDutyLambdaFunctionSubscription: 136 | Type: AWS::SNS::Subscription 137 | Condition: Subscribe 138 | Properties: 139 | Endpoint: !GetAtt [EnableGuardDutyLambdaFunction, Arn] 140 | Protocol: lambda 141 | TopicArn: !Ref pNewAccountTopicArn 142 | 143 | Outputs: 144 | StackName: 145 | Value: !Ref AWS::StackName 146 | LambdaBundle: 147 | Value: !Ref pLambdaZipFile 148 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | ifndef BUCKET 4 | $(error BUCKET is not set) 5 | endif 6 | 7 | ifndef version 8 | export version := $(shell date +%Y%b%d-%H%M) 9 | endif 10 | 11 | # Specific to this stack 12 | export ENABLE_STACK_NAME ?= GuardDuty-Enable 13 | export SPLUNK_STACK_NAME ?= GuardDuty2Splunk 14 | 15 | # Filename for the CFT to deploy 16 | export ENABLE_STACK_TEMPLATE=cloudformation/GuardDuty-Enable-Template.yaml 17 | export SPLUNK_STACK_TEMPLATE=cloudformation/GuardDuty2Splunk-Template.yaml 18 | 19 | 20 | # Name of the Zip file with all the function code and dependencies 21 | export LAMBDA_PACKAGE=$(ENABLE_STACK_NAME)-lambda-$(version).zip 22 | 23 | # Name of the manifest file. 24 | export ENABLE_MANIFEST=cloudformation/$(ENABLE_STACK_NAME)-Manifest.yaml 25 | export SPLUNK_MANIFEST=cloudformation/$(SPLUNK_STACK_NAME)-Manifest.yaml 26 | 27 | # location in the Antiope bucket where we drop lambda-packages 28 | export OBJECT_KEY=deploy-packages/$(LAMBDA_PACKAGE) 29 | 30 | 31 | # List of all the functions deployed by this stack. Required for "make update" to work. 32 | FUNCTIONS = $(ENABLE_STACK_NAME)-enable-guardduty 33 | 34 | .PHONY: $(FUNCTIONS) 35 | 36 | # Run all tests 37 | test: cfn-validate 38 | cd lambda && $(MAKE) test 39 | 40 | # Do everything 41 | enable-deploy: package upload enable-cfn-deploy 42 | 43 | clean: 44 | cd lambda && $(MAKE) clean 45 | 46 | # 47 | # Cloudformation Targets 48 | # 49 | 50 | # target to generate a manifest file. Only do this once 51 | enable-manifest: 52 | cft-generate-manifest -t $(ENABLE_STACK_TEMPLATE) -m $(ENABLE_MANIFEST) --stack-name $(ENABLE_STACK_NAME) --region $(AWS_DEFAULT_REGION) 53 | 54 | 55 | 56 | # Validate the template 57 | cfn-validate: $(ENABLE_STACK_TEMPLATE) $(SPLUNK_STACK_TEMPLATE) 58 | cft-validate --region $(AWS_DEFAULT_REGION) -t $(ENABLE_STACK_TEMPLATE) 59 | cft-validate --region $(AWS_DEFAULT_REGION) -t $(SPLUNK_STACK_TEMPLATE) 60 | 61 | 62 | # Enable Lambda Stack Targets 63 | 64 | enable-validate-manifest: cfn-validate 65 | cft-validate-manifest --region $(AWS_DEFAULT_REGION) -m $(ENABLE_MANIFEST) pLambdaZipFile=$(OBJECT_KEY) pDeployBucket=$(BUCKET) 66 | 67 | # Deploy the stack 68 | enable-cfn-deploy: cfn-validate $(ENABLE_MANIFEST) 69 | cft-deploy -m $(ENABLE_MANIFEST) pLambdaZipFile=$(OBJECT_KEY) pDeployBucket=$(BUCKET) --force 70 | 71 | # 72 | # Splunk Deploy Stack Target 73 | # 74 | splunk-manifest: 75 | cft-generate-manifest -t $(SPLUNK_STACK_TEMPLATE) -m $(SPLUNK_MANIFEST) --stack-name $(SPLUNK_STACK_NAME) 76 | 77 | splunk-deploy: cfn-validate $(SPLUNK_MANIFEST) 78 | $(eval REGIONS := $(shell aws ec2 describe-regions --output text | awk '{print $$NF}')) 79 | for r in $(REGIONS) ; do \ 80 | cft-deploy -m $(SPLUNK_MANIFEST) --override-region $$r --force ; \ 81 | done 82 | 83 | 84 | 85 | # 86 | # Lambda Targets 87 | # 88 | package: 89 | cd lambda && $(MAKE) package 90 | 91 | zipfile: 92 | cd lambda && $(MAKE) zipfile 93 | 94 | upload: package 95 | aws s3 cp lambda/$(LAMBDA_PACKAGE) s3://$(BUCKET)/$(OBJECT_KEY) 96 | 97 | # # Update the Lambda Code without modifying the CF Stack 98 | update: package $(FUNCTIONS) 99 | for f in $(FUNCTIONS) ; do \ 100 | aws lambda update-function-code --function-name $$f --zip-file fileb://lambda/$(LAMBDA_PACKAGE) ; \ 101 | done 102 | 103 | # Update one specific function. Called as "make fupdate function=-aws-inventory-ecs-inventory" 104 | fupdate: zipfile 105 | aws lambda update-function-code --function-name $(function) --zip-file fileb://lambda/$(LAMBDA_PACKAGE) ; \ 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | # # Shouldn't be overridden 121 | # export AWS_LAMBDA_FUNCTION_PREFIX ?= aws-guardduty-enterprise 122 | # export AWS_TEMPLATE ?= cloudformation/GuardDuty-Template.yaml 123 | # export LAMBDA_PACKAGE ?= lambda-$(version).zip 124 | # export manifest ?= cloudformation/GuardDuty-Manifest-$(env).yaml 125 | # export AWS_LAMBDA_FUNCTION_NAME=$(AWS_LAMBDA_FUNCTION_PREFIX)-$(env) 126 | # export OBJECT_KEY ?= $(AWS_LAMBDA_FUNCTION_PREFIX)/$(LAMBDA_PACKAGE) 127 | 128 | # FUNCTIONS = $(AWS_LAMBDA_FUNCTION_NAME)-enable-guardduty 129 | 130 | # .PHONY: $(FUNCTIONS) 131 | 132 | # # Run all tests 133 | # test: cfn-validate 134 | # cd lambda && $(MAKE) test 135 | 136 | # deploy: package upload cfn-deploy 137 | 138 | # clean: 139 | # cd lambda && $(MAKE) clean 140 | 141 | # # 142 | # # Cloudformation Targets 143 | # # 144 | 145 | # # Validate the template 146 | # cfn-validate: $(AWS_TEMPLATE) 147 | # aws cloudformation validate-template --region us-east-1 --template-body file://$(AWS_TEMPLATE) 148 | 149 | # # Deploy the stack 150 | # cfn-deploy: cfn-validate $(manifest) 151 | # deploy_stack.rb -m $(manifest) pLambdaZipFile=$(OBJECT_KEY) pDeployBucket=$(DEPLOYBUCKET) pEnvironment=$(env) --force 152 | 153 | # # 154 | # # Lambda Targets 155 | # # 156 | # package: 157 | # cd lambda && $(MAKE) package 158 | 159 | # upload: 160 | # aws s3 cp lambda/$(LAMBDA_PACKAGE) s3://$(DEPLOYBUCKET)/$(OBJECT_KEY) 161 | 162 | # # Update the Lambda Code without modifying the CF Stack 163 | # update: package $(FUNCTIONS) 164 | # for f in $(FUNCTIONS) ; do \ 165 | # aws lambda update-function-code --function-name $$f --zip-file fileb://lambda/$(LAMBDA_PACKAGE) ; \ 166 | # done 167 | 168 | # # Update a specific Lambda function 169 | # fupdate: package 170 | # aws lambda update-function-code --function-name $(function) --zip-file fileb://lambda/$(LAMBDA_PACKAGE) ; \ 171 | -------------------------------------------------------------------------------- /cloudformation/GuardDuty2Splunk-Template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: Deploy Lambda and other resources for AWS GuardDuty Management 3 | 4 | Parameters: 5 | 6 | pHECSecretName: 7 | Description: ARN of the Secrets Manager Secret with the HEC Endpoint and HEC Token 8 | Type: String 9 | 10 | pHECSecretRegion: 11 | Description: Region where the HEC Token secret is stored 12 | Type: String 13 | 14 | Resources: 15 | 16 | GuardDuty2SplunkLambdaRole: 17 | Type: AWS::IAM::Role 18 | Properties: 19 | AssumeRolePolicyDocument: 20 | Version: '2012-10-17' 21 | Statement: 22 | - Effect: Allow 23 | Principal: 24 | Service: 25 | - lambda.amazonaws.com 26 | Action: 27 | - sts:AssumeRole 28 | Path: / 29 | Policies: 30 | - PolicyName: LambdaLogging 31 | PolicyDocument: 32 | Version: '2012-10-17' 33 | Statement: 34 | - Resource: '*' 35 | Action: 36 | - logs:* 37 | Effect: Allow 38 | - PolicyName: GetSecret 39 | PolicyDocument: 40 | Version: '2012-10-17' 41 | Statement: 42 | - Effect: "Allow" 43 | Action: 44 | - secretsmanager:GetSecret* 45 | Resource: !Sub "arn:aws:secretsmanager:${pHECSecretRegion}:${AWS::AccountId}:secret:${pHECSecretName}-*" 46 | 47 | 48 | GuardDuty2SplunkLambdaFunction: 49 | Type: AWS::Lambda::Function 50 | Properties: 51 | FunctionName: !Sub "${AWS::StackName}-lambda" 52 | Description: Push a CloudWatch Event to Splunk 53 | Handler: index.handler 54 | Runtime: python3.6 55 | Timeout: 300 56 | MemorySize: 768 57 | Role: !GetAtt GuardDuty2SplunkLambdaRole.Arn 58 | Environment: 59 | Variables: 60 | HEC_DATA: !Ref pHECSecretName 61 | SECRET_REGION: !Ref pHECSecretRegion 62 | Code: 63 | ZipFile: | 64 | import json 65 | import os 66 | import boto3 67 | from botocore.exceptions import ClientError 68 | import botocore.vendored.requests as requests 69 | from botocore.vendored.requests.exceptions import RequestException 70 | 71 | import logging 72 | logger = logging.getLogger() 73 | logger.setLevel(logging.INFO) 74 | # Quiet Boto3 75 | logging.getLogger('botocore').setLevel(logging.WARNING) 76 | logging.getLogger('boto3').setLevel(logging.WARNING) 77 | 78 | 79 | def handler(event, _context): 80 | logger.debug("Received event: " + json.dumps(event, sort_keys=True)) 81 | hec_data = get_secret(os.environ['HEC_DATA'], os.environ['SECRET_REGION']) 82 | if hec_data is None: 83 | logger.critical(f"Unable to fetch secret {os.environ['HEC_DATA']}") 84 | raise Exception 85 | logger.debug(f"HEC Endpoint: {hec_data['HECEndpoint']}") 86 | 87 | message = { 88 | 'event': event 89 | } 90 | try: 91 | r = requests.post(hec_data['HECEndpoint'], 92 | headers={"Authorization": f"Splunk {hec_data['HECToken']}"}, 93 | data=json.dumps(message)) 94 | if r.status_code != 200: 95 | logger.critical(f"Error: {r.text}") 96 | except RequestException as e: 97 | logger.critical(f"Error: {str(e)}") 98 | 99 | 100 | def get_secret(secret_name, region): 101 | # Create a Secrets Manager client 102 | session = boto3.session.Session() 103 | client = session.client(service_name='secretsmanager', region_name=region) 104 | 105 | try: 106 | get_secret_value_response = client.get_secret_value( 107 | SecretId=secret_name 108 | ) 109 | except ClientError as e: 110 | logger.critical(f"Client error {e} getting secret") 111 | raise e 112 | 113 | else: 114 | # Decrypts secret using the associated KMS CMK. 115 | # Depending on whether the secret is a string or binary, one of these 116 | # fields will be populated. 117 | if 'SecretString' in get_secret_value_response: 118 | secret = get_secret_value_response['SecretString'] 119 | return json.loads(secret) 120 | else: 121 | decoded_binary_secret = base64.b64decode(get_secret_value_response['SecretBinary']) 122 | return(decoded_binary_secret) 123 | return None 124 | 125 | ### END OF CODE ### 126 | 127 | 128 | GuardDutyCloudWatchEvent: 129 | Type: AWS::Events::Rule 130 | Properties: 131 | Description: GuardDutyRuleForSplunk 132 | State: ENABLED 133 | EventPattern: 134 | source: 135 | - aws.guardduty 136 | Targets: 137 | - Arn: !GetAtt GuardDuty2SplunkLambdaFunction.Arn 138 | Id: GuardDutyFunction 139 | 140 | LambdaInvokePermission: 141 | Type: AWS::Lambda::Permission 142 | Properties: 143 | Action: lambda:InvokeFunction 144 | Principal: events.amazonaws.com 145 | FunctionName: !Ref GuardDuty2SplunkLambdaFunction 146 | SourceArn: !GetAtt GuardDutyCloudWatchEvent.Arn 147 | 148 | 149 | Outputs: 150 | StackName: 151 | Value: !Ref AWS::StackName 152 | -------------------------------------------------------------------------------- /scripts/enable_guardduty.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import boto3 4 | from botocore.exceptions import ClientError, EndpointConnectionError 5 | import base64 6 | import time 7 | import os 8 | import logging 9 | import time 10 | 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | # Quiet Boto3 15 | logging.getLogger('botocore').setLevel(logging.WARNING) 16 | logging.getLogger('boto3').setLevel(logging.WARNING) 17 | 18 | # Deploy Guard Duty across all child accounts to the payer account 19 | # Process documented here: https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_accounts.html#guardduty_become_api 20 | 21 | DEFAULT_MESSAGE='Parent Account is enabling GuardDuty. No Action Required.' 22 | 23 | DRY_RUN=False 24 | 25 | def create_parent_detector(gd_client, region): 26 | if DRY_RUN: 27 | logger.info("Need to create a Detector in {} for the GuardDuty Master account".format(region)) 28 | return(None) 29 | 30 | logger.info("Creating a Detector in {} for the GuardDuty Master account".format(region)) 31 | try: 32 | response = gd_client.create_detector(Enable=True) 33 | return(response['DetectorId']) 34 | except ClientError as e: 35 | logger.error("Failed to create detector in {}. Aborting...".format(region)) 36 | exit(1) 37 | 38 | def get_all_members(region, gd_client, detector_id): 39 | output = {} 40 | response = gd_client.list_members(DetectorId=detector_id, MaxResults=50) 41 | while 'NextToken' in response: 42 | for a in response['Members']: 43 | # Convert to a lookup table 44 | output[a['AccountId']] = a 45 | response = gd_client.list_members(DetectorId=detector_id, MaxResults=50, NextToken=response['NextToken']) 46 | for a in response['Members']: 47 | # Convert to a lookup table 48 | output[a['AccountId']] = a 49 | 50 | return(output) 51 | 52 | def process_region(args, region): 53 | print("Processing Region {}".format(region)) 54 | gd_client = boto3.client('guardduty', region_name=region) 55 | org_client = boto3.client('organizations') 56 | 57 | # An account can only have one detector per region 58 | try: 59 | response = gd_client.list_detectors() 60 | if len(response['DetectorIds']) == 0: 61 | # We better create one 62 | detector_id = create_parent_detector(gd_client, region) 63 | else: 64 | detector_id = response['DetectorIds'][0] 65 | except ClientError as e: 66 | logger.error("Unable to list detectors in region {}. Skipping this region.".format(region)) 67 | return(False) 68 | except EndpointConnectionError as e: 69 | logger.error("Unable to list detectors in region {}. Skipping this region.".format(region)) 70 | return(False) 71 | 72 | 73 | gd_status = get_all_members(region, gd_client, detector_id) 74 | 75 | payer_account_list = get_consolidated_billing_subaccounts(args) 76 | for a in payer_account_list: 77 | if a['Status'] != "ACTIVE": 78 | continue 79 | if a['Id'] not in gd_status: 80 | if DRY_RUN: 81 | print("Need to enable GuardDuty for {}({})".format(a['Name'], a['Id'])) 82 | else: 83 | print("Enabling GuardDuty for {}({})".format(a['Name'], a['Id'])) 84 | if not args.accept_only: 85 | invite_account(a, detector_id, region, args.message) 86 | time.sleep(3) 87 | accept_invite(a, args.assume_role, region) 88 | # exit(1) 89 | continue 90 | if gd_status[a['Id']]['RelationshipStatus'] == "Enabled": 91 | # print("{}({}) is already enabled for GuardDuty in {}".format(a['Name'], a['Id'], region)) 92 | continue 93 | print("{}({}) is in unexpected state {} for GuardDuty in {}".format(a['Name'], a['Id'], gd_status[a['Id']]['RelationshipStatus'], region)) 94 | return() 95 | 96 | def invite_account(account, detector_id, region, message): 97 | if DRY_RUN: 98 | print("Need to Invite {}({}) to this GuardDuty Master".format(account['Name'], account['Id'])) 99 | return(None) 100 | client = boto3.client('guardduty', region_name=region) 101 | print("Inviting {}({}) to this GuardDuty Master".format(account['Name'], account['Id'])) 102 | response = client.create_members( 103 | AccountDetails=[ 104 | { 105 | 'AccountId': account['Id'], 106 | 'Email': account['Email'] 107 | }, 108 | ], 109 | DetectorId=detector_id 110 | ) 111 | response = client.invite_members( 112 | AccountIds=[ account['Id'] ], 113 | DetectorId=detector_id, 114 | DisableEmailNotification=True 115 | ) 116 | 117 | def accept_invite(account, role_name, region): 118 | if DRY_RUN: 119 | print("Need to accept invite in {}({})".format(account['Name'], account['Id'])) 120 | return(None) 121 | print("Accepting invite in {}({})".format(account['Name'], account['Id'])) 122 | organization_role_arn = "arn:aws:iam::{}:role/{}" 123 | session_creds = get_creds(organization_role_arn.format(account['Id'], role_name)) 124 | if session_creds is False: 125 | print("Unable to assume role into {}({}) to accept the invite".format(account['Name'], account['Id'])) 126 | return(False) 127 | child_client = boto3.client('guardduty', region_name=region, 128 | aws_access_key_id = session_creds['AccessKeyId'], 129 | aws_secret_access_key = session_creds['SecretAccessKey'], 130 | aws_session_token = session_creds['SessionToken'] 131 | ) 132 | response = child_client.list_detectors() 133 | if len(response['DetectorIds']) == 0: 134 | response = child_client.create_detector(Enable=True) 135 | detector_id = response['DetectorId'] 136 | else: 137 | detector_id = response['DetectorIds'][0] 138 | response = child_client.list_invitations() 139 | for i in response['Invitations']: 140 | response = child_client.accept_invitation( 141 | DetectorId=detector_id, 142 | InvitationId=i['InvitationId'], 143 | MasterId=i['AccountId'] 144 | ) 145 | 146 | def get_creds(role_arn): 147 | client = boto3.client('sts') 148 | try: 149 | session = client.assume_role(RoleArn=role_arn, RoleSessionName="EnableGuardDuty") 150 | return(session['Credentials']) 151 | except Exception as e: 152 | print(u"Failed to assume role {}: {}".format(role_arn, e)) 153 | return(False) 154 | # end get_payer_creds() 155 | 156 | def get_consolidated_billing_subaccounts(args): 157 | # Returns: [ 158 | # { 159 | # 'Id': 'string', 160 | # 'Arn': 'string', 161 | # 'Email': 'string', 162 | # 'Name': 'string', 163 | # 'Status': 'ACTIVE'|'SUSPENDED', 164 | # 'JoinedMethod': 'INVITED'|'CREATED', 165 | # 'JoinedTimestamp': datetime(2015, 1, 1) 166 | # }, 167 | # ], 168 | if args.payer_arn is not None: 169 | payer_creds = get_creds(args.payer_arn) 170 | if payer_creds == False: 171 | print("Unable to assume role in payer {}".format(args.payer_arn)) 172 | exit(1) 173 | 174 | org_client = boto3.client('organizations', 175 | aws_access_key_id = payer_creds['AccessKeyId'], 176 | aws_secret_access_key = payer_creds['SecretAccessKey'], 177 | aws_session_token = payer_creds['SessionToken'] 178 | ) 179 | else: 180 | org_client = boto3.client('organizations') 181 | 182 | output = [] 183 | 184 | try: 185 | # If we're only supposed to do one account, just get that from the payer and returnn 186 | if args.account_id: 187 | response = org_client.describe_account( AccountId=args.account_id ) 188 | output.append(response['Account']) 189 | return(output) 190 | 191 | # Otherwise, gotta catch 'em all 192 | response = org_client.list_accounts( MaxResults=20 ) 193 | while 'NextToken' in response : 194 | output = output + response['Accounts'] 195 | response = org_client.list_accounts( MaxResults=20, NextToken=response['NextToken'] ) 196 | 197 | output = output + response['Accounts'] 198 | return(output) 199 | except ClientError as e: 200 | print("Unable to get account details from Organizational Parent: {}.\nAborting...".format(e)) 201 | exit(1) 202 | 203 | def do_args(): 204 | import argparse 205 | parser = argparse.ArgumentParser() 206 | parser.add_argument("--debug", help="print debugging info", action='store_true') 207 | parser.add_argument("--error", help="print error info only", action='store_true') 208 | 209 | 210 | # 211 | # Required 212 | # 213 | parser.add_argument("--account_id", help="AWS Account ID") 214 | parser.add_argument("--payer_arn", help="Assume this role to get the list of accounts") 215 | parser.add_argument("--assume_role", help="Name of the Role to assume in Child Accounts", default="OrganizationAccountAccessRole") 216 | parser.add_argument("--region", help="Only run in this region", default="ALL") 217 | 218 | parser.add_argument("--message", help="Custom Message sent to child as part of invite", default=DEFAULT_MESSAGE) 219 | parser.add_argument("--accept_only", help="Accept existing invite again", action='store_true') 220 | parser.add_argument("--dry-run", help="Only print what needs to happen", action='store_true') 221 | 222 | 223 | 224 | args = parser.parse_args() 225 | 226 | # Logging idea stolen from: https://docs.python.org/3/howto/logging.html#configuring-logging 227 | # create console handler and set level to debug 228 | ch = logging.StreamHandler() 229 | if args.debug: 230 | ch.setLevel(logging.DEBUG) 231 | elif args.error: 232 | ch.setLevel(logging.ERROR) 233 | else: 234 | ch.setLevel(logging.INFO) 235 | # create formatter 236 | # formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 237 | formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s') 238 | # add formatter to ch 239 | ch.setFormatter(formatter) 240 | # add ch to logger 241 | logger.addHandler(ch) 242 | 243 | # if not hasattr(args, 'environment_id'): 244 | # print("Must specify --environment_id") 245 | # exit(1) 246 | 247 | return(args) 248 | 249 | 250 | 251 | if __name__ == '__main__': 252 | args = do_args() 253 | 254 | if args.dry_run: 255 | print("Only doing a DryRun...") 256 | DRY_RUN = True 257 | 258 | regions = [] 259 | if args.region == "ALL": 260 | ec2 = boto3.client('ec2') 261 | response = ec2.describe_regions() 262 | for r in response['Regions']: 263 | regions.append(r['RegionName']) 264 | else: 265 | regions.append(args.region) 266 | 267 | for r in regions: 268 | process_region(args, r) 269 | 270 | 271 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /lambda/enable_guardduty.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import boto3 4 | from botocore.exceptions import ClientError 5 | import json 6 | import logging 7 | import os 8 | import time 9 | import io 10 | 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.DEBUG) 14 | # Quiet Boto3 15 | logging.getLogger('botocore').setLevel(logging.WARNING) 16 | logging.getLogger('boto3').setLevel(logging.WARNING) 17 | 18 | # Deploy Guard Duty across all child accounts to the payer account 19 | # Process documented here: 20 | # https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_accounts.html#guardduty_become_api 21 | 22 | 23 | def handler(event, context): 24 | ''' 25 | message = { 26 | 'account_id': 'string', 27 | 'dry_run': true|false, // optional, if un-specified, dry_run=false 28 | 'region': ['string'], // optional, if un-specified, runs all regions 29 | } 30 | ''' 31 | logger.debug("Received event: " + json.dumps(event, sort_keys=True)) 32 | message = json.loads(event['Records'][0]['Sns']['Message']) 33 | 34 | # Setup Logger to save for an email 35 | # Stolen from http://alanwsmith.com/capturing-python-log-output-in-a-variable 36 | log_capture_string = io.StringIO() 37 | ch = logging.StreamHandler(log_capture_string) 38 | ch.setLevel(logging.INFO) 39 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 40 | ch.setFormatter(formatter) 41 | logger.addHandler(ch) 42 | 43 | logger.info("Received message: " + json.dumps(message, sort_keys=True)) 44 | 45 | # account_id to operate on must be specified 46 | if "account_id" not in message: 47 | error_message = "message['account_id'] must be specified" 48 | logger.error(error_message) 49 | raise KeyError(error_message) 50 | 51 | # get parent organization's account_id 52 | message['payer_account_id'] = get_parent_organization_account_id(message) 53 | logger.info(f"Found payer account_id: {message['payer_account_id']}") 54 | 55 | # describe account (from payer account) 56 | message["account_info"] = describe_account(message) 57 | 58 | # add optional message attributes as necessary 59 | process_message(message) 60 | 61 | # process each region in the request 62 | for region in message['region']: 63 | process_region(message, region) 64 | 65 | # Now send an email 66 | log_body = log_capture_string.getvalue() 67 | send_email(log_body, message["account_info"], os.environ['EMAIL_TO'], os.environ['EMAIL_FROM'], context.function_name) 68 | 69 | 70 | def process_region(event, region): 71 | logger.info(f"Processing Region: {region}") 72 | 73 | # Local client in the GD Master account 74 | gd_client = boto3.client('guardduty', region_name=region) 75 | try: 76 | response = gd_client.list_detectors() 77 | if len(response['DetectorIds']) == 0: 78 | # We better create one 79 | detector_id = create_masteraccount_detector(gd_client, event, region) 80 | else: 81 | # An account can only have one detector per region 82 | detector_id = response['DetectorIds'][0] 83 | except ClientError as e: 84 | logger.error("Unable to list detectors in region {}. Skipping this region.".format(region)) 85 | return(False) 86 | except EndpointConnectionError as e: 87 | logger.error("Unable to connect to GuardDuty in region {}. Skipping this region.".format(region)) 88 | return(False) 89 | 90 | gd_status = get_all_members(region, gd_client, detector_id) 91 | 92 | account = event['account_info'] 93 | account_name = account['Name'] 94 | account_id = account['Id'] 95 | if account['Status'] != "ACTIVE": 96 | logger.info(f"Account {account_name}({account_id}) is inactive. No action being taken.") 97 | return 98 | 99 | if account_id not in gd_status: 100 | if event["dry_run"]: 101 | logger.info(f"Need to enable GuardDuty for {account_name}({account_id})") 102 | else: 103 | logger.info(f"Enabling GuardDuty for {account_name}({account_id})") 104 | if "accept_only" not in event or not event["accept_only"]: 105 | invite_account(account, detector_id, gd_client, event, region) 106 | time.sleep(1) 107 | accept_invite(account, os.environ['ACCEPT_ROLE'], event, region) 108 | elif gd_status[account_id]['RelationshipStatus'] == "Enabled": 109 | logger.info(f"{account_name}({account_id}) is already GuardDuty-enabled in {region}") 110 | else: 111 | logger.error(f"{account_name}({account_id}) is in unexpected GuardDuty state " 112 | f"{gd_status[account_id]['RelationshipStatus']} in {region}") 113 | 114 | 115 | def create_masteraccount_detector(gd_client, event, region): 116 | if event["dry_run"]: 117 | logger.info(f"Need to create a Detector in {region} for the GuardDuty Master account") 118 | return(None) 119 | 120 | logger.info(f"Creating a Detector in {region} for the GuardDuty Master account") 121 | try: 122 | response = gd_client.create_detector(Enable=True) 123 | return(response['DetectorId']) 124 | except ClientError as e: 125 | logger.error(f"Failed to create detector in {region}. Aborting...") 126 | raise 127 | 128 | 129 | def get_all_members(region, gd_client, detector_id): 130 | output = {} 131 | response = gd_client.list_members(DetectorId=detector_id, MaxResults=50) 132 | while 'NextToken' in response: 133 | for a in response['Members']: 134 | # Convert to a lookup table 135 | output[a['AccountId']] = a 136 | response = gd_client.list_members( 137 | DetectorId=detector_id, 138 | MaxResults=50, 139 | NextToken=response['NextToken'], 140 | ) 141 | for a in response['Members']: 142 | # Convert to a lookup table 143 | output[a['AccountId']] = a 144 | 145 | return(output) 146 | 147 | 148 | def invite_account(account, detector_id, gd_client, event, region): 149 | if event["dry_run"]: 150 | logger.info(f"Need to Invite {account['Name']}({account['Id']}) to this GuardDuty Master") 151 | return(None) 152 | 153 | logger.info(f"Inviting {account['Name']}({account['Id']}) to this GuardDuty Master") 154 | gd_client.create_members( 155 | AccountDetails=[ 156 | { 157 | 'AccountId': account['Id'], 158 | 'Email': account['Email'], 159 | }, 160 | ], 161 | DetectorId=detector_id, 162 | ) 163 | gd_client.invite_members( 164 | AccountIds=[account['Id']], 165 | DetectorId=detector_id, 166 | DisableEmailNotification=True, 167 | ) 168 | 169 | 170 | def accept_invite(account, role_name, event, region): 171 | if event["dry_run"]: 172 | logger.info(f"Need to accept invite in {account['Name']}({account['Id']})") 173 | return(None) 174 | 175 | logger.info(f"Accepting invite in {account['Name']}({account['Id']})") 176 | 177 | role_arn = create_role_arn(account['Id'], role_name) 178 | creds = get_creds(role_arn) 179 | 180 | child_client = boto3.client( 181 | 'guardduty', 182 | region_name=region, 183 | aws_access_key_id=creds['AccessKeyId'], 184 | aws_secret_access_key=creds['SecretAccessKey'], 185 | aws_session_token=creds['SessionToken'], 186 | ) 187 | 188 | response = child_client.list_detectors() 189 | if len(response['DetectorIds']) == 0: 190 | response = child_client.create_detector(Enable=True) 191 | detector_id = response['DetectorId'] 192 | else: 193 | detector_id = response['DetectorIds'][0] 194 | 195 | response = child_client.list_invitations() 196 | for i in response['Invitations']: 197 | child_client.accept_invitation( 198 | DetectorId=detector_id, 199 | InvitationId=i['InvitationId'], 200 | MasterId=i['AccountId'], 201 | ) 202 | 203 | 204 | def create_role_arn(account_id, role_name): 205 | return f"arn:aws:iam::{account_id}:role/{role_name}" 206 | 207 | 208 | def get_creds(role_arn): 209 | client = boto3.client('sts') 210 | try: 211 | session = client.assume_role( 212 | RoleArn=role_arn, RoleSessionName="EnableGuardDuty", 213 | ) 214 | return(session['Credentials']) 215 | except Exception as e: 216 | logger.error(f"Failed to assume role {role_arn}: {e}") 217 | raise 218 | 219 | 220 | def get_parent_organization_account_id(event): 221 | role_arn = create_role_arn(event['account_id'], os.environ['ACCEPT_ROLE']) 222 | creds = get_creds(role_arn) 223 | org_client = boto3.client( 224 | 'organizations', 225 | aws_access_key_id=creds['AccessKeyId'], 226 | aws_secret_access_key=creds['SecretAccessKey'], 227 | aws_session_token=creds['SessionToken'], 228 | ) 229 | response = org_client.describe_organization() 230 | return response['Organization']['MasterAccountId'] 231 | 232 | 233 | def describe_account(event): 234 | ''' 235 | Returns: { 236 | 'Id': 'string', 237 | 'Arn': 'string', 238 | 'Email': 'string', 239 | 'Name': 'string', 240 | 'Status': 'ACTIVE'|'SUSPENDED', 241 | 'JoinedMethod': 'INVITED'|'CREATED', 242 | 'JoinedTimestamp': datetime(2015, 1, 1) 243 | } 244 | ''' 245 | role_arn = create_role_arn(event["payer_account_id"], os.environ["AUDIT_ROLE"]) 246 | creds = get_creds(role_arn) 247 | org_client = boto3.client( 248 | 'organizations', 249 | aws_access_key_id=creds['AccessKeyId'], 250 | aws_secret_access_key=creds['SecretAccessKey'], 251 | aws_session_token=creds['SessionToken'] 252 | ) 253 | try: 254 | response = org_client.describe_account(AccountId=event["account_id"]) 255 | return response['Account'] 256 | except ClientError as e: 257 | logger.error( 258 | f"Unable to get account details from Organizational Parent: {e}.\nAborting...") 259 | raise 260 | 261 | 262 | def process_message(message): 263 | '''Add in the optional elements of the message''' 264 | 265 | if "dry_run" not in message: 266 | logger.info("message['dry_run'] not specified; default = False") 267 | message['dry_run'] = False 268 | 269 | if "region" not in message or not message["region"]: 270 | logger.info("message['region'] not specified; default = all regions") 271 | ec2 = boto3.client('ec2') 272 | response = ec2.describe_regions() 273 | message['region'] = [r['RegionName'] for r in response['Regions']] 274 | 275 | 276 | def send_email(log_body, account, to_addr, from_addr, function_name): 277 | 278 | SENDER = f"{function_name} <{from_addr}>" 279 | message_body = f""" 280 | GuardDuty was enabled for account {account['Name']} ({account['Id']}) in all regions. 281 | 282 | The Log Body follows: 283 | {log_body} 284 | 285 | ** This is an autogenerated email from the lambda {function_name} ** 286 | """ 287 | 288 | client = boto3.client('ses', region_name="us-east-1") # SES only in a few regions 289 | response = client.send_email( 290 | Source=SENDER, 291 | Destination={'ToAddresses': [to_addr]}, 292 | Message={ 293 | 'Subject': {'Data': f"GuardDuty Enabled for {account['Name']} ({account['Id']})" }, 294 | 'Body': {'Text': {'Data': message_body } } 295 | } 296 | ) 297 | 298 | if __name__ == '__main__': 299 | import argparse 300 | parser = argparse.ArgumentParser() 301 | parser.add_argument("--debug", help="log debugging info", action='store_true') 302 | parser.add_argument("--error", help="log error info only", action='store_true') 303 | 304 | # 305 | # Required 306 | # 307 | parser.add_argument("--account_id", help="AWS Account ID", required=True) 308 | parser.add_argument("--audit_role", help="Name of role to assume in payer account", required=True) 309 | parser.add_argument("--accept_role", help="Name of the role to assume in child accounts", required=True) 310 | parser.add_argument("--region", help="Only run in this region (list)") 311 | # parser.add_argument("--message", help="Custom Message sent to child as part of invite") 312 | 313 | parser.add_argument("--accept_only", help="Accept existing invite", action='store_true') 314 | parser.add_argument("--dry-run", help="Don't actually do it", action='store_true') 315 | 316 | args = parser.parse_args() 317 | 318 | # Logging idea from: https://docs.python.org/3/howto/logging.html#configuring-logging 319 | # create console handler and set level to debug 320 | ch = logging.StreamHandler() 321 | if args.debug: 322 | ch.setLevel(logging.DEBUG) 323 | elif args.error: 324 | ch.setLevel(logging.ERROR) 325 | else: 326 | ch.setLevel(logging.INFO) 327 | 328 | # create formatter 329 | formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s') 330 | # add formatter to ch 331 | ch.setFormatter(formatter) 332 | # add ch to logger 333 | logger.addHandler(ch) 334 | 335 | # Build the Message structure 336 | message = {} 337 | if args.account_id: 338 | message['account_id'] = args.account_id 339 | # if args.message: 340 | # message['message'] = args.message 341 | if args.dry_run: 342 | message['dry_run'] = True 343 | if args.region: 344 | message['region'] = args.region 345 | 346 | os.environ['ACCEPT_ROLE'] = args.accept_role 347 | os.environ['AUDIT_ROLE'] = args.audit_role 348 | 349 | event = { 350 | 'Records': [ 351 | { 352 | 'Sns': { 353 | 'Message': json.dumps(message), 354 | } 355 | } 356 | ] 357 | } 358 | context = {} 359 | handler(event, context) 360 | --------------------------------------------------------------------------------