├── lambda ├── function.zip ├── test.js ├── index.js └── slack.js ├── readme.md ├── .gitignore ├── LICENSE └── 2020-02_s3_dlp_flow.svg /lambda/function.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkbitio/aws-s3-dlp/HEAD/lambda/function.zip -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Simple S3 DLP 2 | 3 | This is a tool to monitor unauthorized or unexpected data transfer from S3 buckets in your org to an external account. It works by triggering CloudWatch rules generated by S3 API `CopyObject` events. These events are then sent to an SNS Topic, which in turn invoke a Lambda function to parse the event and send a Slack notification if objects were copied to an external account. 4 | 5 | ![flow](2020-02_s3_dlp_flow.svg) 6 | 7 | ### Setup 8 | 9 | The step-by-step setup instructions can be found in [this blog post](https://darkbit.io/blog/2020/02/18/simple-dlp-for-amazon-s3). 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # Crash log files 9 | crash.log 10 | 11 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 12 | # .tfvars files are managed as part of configuration and so should be included in 13 | # version control. 14 | # 15 | # example.tfvars 16 | 17 | # Ignore override files as they are usually used to override resources locally and so 18 | # are not checked in 19 | override.tf 20 | override.tf.json 21 | *_override.tf 22 | *_override.tf.json 23 | 24 | # Include override files you do wish to add to version control using negated pattern 25 | # 26 | # !example_override.tf 27 | 28 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 29 | # example: *tfplan* 30 | 31 | .DS_Store 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Darkbit, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lambda/test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Simple test harness for Lambda handler 3 | * 4 | * Run with `node test.js` 5 | */ 6 | 7 | const { handler } = require('./index') 8 | 9 | /* 10 | * Sample event message payload 11 | */ 12 | const message = { 13 | detail: { 14 | awsRegion: 'us-east-1', 15 | userAgent: 'aws-cli/1.16.310 Python/3.7.4 Darwin/19.2.0 botocore/1.13.46', 16 | userIdentity: { arn: 'arn:aws:iam::123456789012:user/brandon.michaels' }, 17 | requestParameters: { 18 | bucketName: 'secret-9876543210', 19 | }, 20 | resources: [ 21 | { 22 | type: 'AWS::S3::Object', 23 | ARN: 'arn:aws:s3:::exfil-1234567890/file1', 24 | }, 25 | { 26 | accountId: '1234567890', 27 | type: 'AWS::S3::Bucket', 28 | ARN: 'arn:aws:s3:::exfil-1234567890', 29 | }, 30 | { 31 | accountId: '9876543210', 32 | type: 'AWS::S3::Bucket', 33 | ARN: 'arn:aws:s3:::secret-9876543210', 34 | }, 35 | { 36 | type: 'AWS::S3::Object', 37 | ARN: 'arn:aws:s3:::secret-9876543210/file1', 38 | }, 39 | ], 40 | }, 41 | } 42 | 43 | /* 44 | * Format a test message with the same shape as an SNS message 45 | */ 46 | const dummy = { 47 | Records: [ 48 | { 49 | Sns: { 50 | Message: JSON.stringify(message), 51 | }, 52 | }, 53 | ], 54 | } 55 | 56 | handler(dummy, {}) 57 | -------------------------------------------------------------------------------- /lambda/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { postSlackMessage } = require('./slack') 3 | 4 | exports.handler = (event, {}) => { 5 | let details = {} 6 | let promises = [] 7 | 8 | // Event contains one or more records from SNS 9 | if (event.Records) { 10 | event.Records.map(record => { 11 | let message = JSON.parse(record.Sns.Message) 12 | 13 | details.resources = message.detail.resources 14 | details.region = message.detail.awsRegion 15 | details.bucket = message.detail.requestParameters.bucketName 16 | details.agent = message.detail.userAgent 17 | details.user = message.detail.userIdentity.arn 18 | 19 | promises.push(this.checkObjectCopyDestination(details)) 20 | }) 21 | } 22 | 23 | Promise.all(promises) 24 | .then(res => { 25 | res.map(response => console.log(response)) 26 | }) 27 | .catch(err => { 28 | console.log(`ERROR: ${err}`) 29 | }) 30 | } 31 | 32 | /* 33 | * Check copied object destination account 34 | */ 35 | exports.checkObjectCopyDestination = async details => { 36 | // const validAccounts = ['9876543210', '1234567890'] 37 | const validAccounts = process.env.AUTHORIZED_ACCOUNTS.split(',') 38 | // compare resources with accountId field 39 | // check for any unknown or unauthorized accounts 40 | const unauthorized = !!details.resources 41 | .filter(resource => resource.accountId) 42 | .filter(resource => !validAccounts.includes(resource.accountId)).length 43 | 44 | // collect object ARNs involved in CopyObject operation 45 | if (unauthorized) { 46 | details.objects = details.resources 47 | .filter(object => object.type === 'AWS::S3::Object') 48 | .map(object => object.ARN) 49 | } 50 | 51 | return new Promise((resolve, reject) => { 52 | if (unauthorized) { 53 | postSlackMessage(details) 54 | .then(() => { 55 | resolve('S3 CopyObject is NOT authorized - posted Slack message') 56 | }) 57 | .catch(err => { 58 | reject(err) 59 | }) 60 | } else { 61 | resolve('S3 CopyObject is authorized') 62 | } 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /lambda/slack.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const https = require('https') 3 | const url = new URL(process.env.SLACK_WEBHOOK) 4 | 5 | /* 6 | * Send a Slack message 7 | */ 8 | exports.postSlackMessage = async message => { 9 | return new Promise((resolve, reject) => { 10 | const body = { 11 | attachments: [ 12 | { 13 | fallback: 'Unauthorized S3 object copied', 14 | color: '#9d1111', 15 | author_name: 'Unauthorized S3 object copied', 16 | title: message.bucket, 17 | title_link: `https://s3.console.aws.amazon.com/s3/buckets/${message.bucket}/`, 18 | text: `S3 object copied to an unknown or unauthorized account.`, 19 | fields: [ 20 | { 21 | title: 'Region', 22 | value: message.region, 23 | short: false, 24 | }, 25 | { 26 | title: 'Initiator', 27 | value: message.user, 28 | short: false, 29 | }, 30 | { 31 | title: 'Agent', 32 | value: message.agent, 33 | short: false, 34 | }, 35 | { 36 | title: 'Objects', 37 | value: message.objects.reverse().join('\n'), 38 | short: false, 39 | }, 40 | ], 41 | footer: 'AWS S3', 42 | footer_icon: 43 | 'https://user-images.githubusercontent.com/2565382/73571350-40a30580-4466-11ea-81e4-e5a36693f164.png', 44 | ts: Math.floor(Date.now() / 1000), 45 | }, 46 | ], 47 | } 48 | const data = JSON.stringify(body) 49 | const options = { 50 | hostname: url.host, 51 | path: url.pathname, 52 | method: 'POST', 53 | headers: { 54 | 'Content-Type': 'application/json', 55 | 'Content-Length': data.length, 56 | }, 57 | } 58 | 59 | const req = https.request(options, res => { 60 | res.on('data', () => { 61 | resolve(res.statusCode) 62 | }) 63 | }) 64 | 65 | req.on('error', err => { 66 | reject(err) 67 | }) 68 | 69 | // send the request 70 | req.write(data) 71 | req.end() 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /2020-02_s3_dlp_flow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
SNS Topic
SNS Topic
Lambda
Lambda
S3 Buckets
S3 Buckets
CloudWatch Events
CloudWatch Eve...
Slack Channel
Slack Chann...
Viewer does not support full SVG 1.1
--------------------------------------------------------------------------------