├── screenshots ├── email.png └── slack-webhook.png ├── internal ├── destination │ └── destination.go ├── destslack │ ├── destslack_test.go │ └── destslack.go ├── destsns │ └── destsns.go └── destses │ └── destses.go ├── go.mod ├── Makefile.example ├── config └── config.go ├── example.tattletail.toml ├── awsstub └── awsstub.go ├── LICENSE ├── testdata └── 1.json ├── go.sum ├── Readme.md ├── cloudtrail_tattletail.go └── cloudtrail_tattletail_test.go /screenshots/email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psanford/cloudtrail-tattletail/HEAD/screenshots/email.png -------------------------------------------------------------------------------- /screenshots/slack-webhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psanford/cloudtrail-tattletail/HEAD/screenshots/slack-webhook.png -------------------------------------------------------------------------------- /internal/destination/destination.go: -------------------------------------------------------------------------------- 1 | package destination 2 | 3 | import "github.com/psanford/cloudtrail-tattletail/config" 4 | 5 | type Loader interface { 6 | Type() string 7 | Load(c config.Destination) (Destination, error) 8 | } 9 | 10 | type Destination interface { 11 | Send(name, desc string, rec map[string]interface{}, matchObj interface{}) error 12 | ID() string 13 | Type() string 14 | } 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/psanford/cloudtrail-tattletail 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/aws/aws-lambda-go v1.32.0 8 | github.com/aws/aws-sdk-go v1.39.4 9 | github.com/go-stack/stack v1.8.0 // indirect 10 | github.com/google/go-cmp v0.5.4 11 | github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac 12 | github.com/itchyny/gojq v0.12.4 13 | github.com/mattn/go-colorable v0.1.8 // indirect 14 | github.com/slack-go/slack v0.9.1 15 | ) 16 | -------------------------------------------------------------------------------- /Makefile.example: -------------------------------------------------------------------------------- 1 | BIN=cloudtrail-tattletail 2 | FUNCTION_NAME=$(BIN) 3 | CONF_S3_BUCKET= 4 | CONF_S3_PATH= 5 | 6 | $(BIN): $(wildcard *.go) $(wildcard **/*.go) 7 | go test . 8 | go build -o $(BIN) 9 | 10 | $(BIN).zip: $(BIN) $(wildcard ./tattletail.toml) 11 | rm -f $@ 12 | zip -r $@ $^ 13 | 14 | .PHONY: upload 15 | upload: $(BIN).zip 16 | aws lambda update-function-code --function-name $(FUNCTION_NAME) --zip-file fileb://$(BIN).zip 17 | rm $(BIN).zip 18 | 19 | .PHONY: upload_config 20 | upload_config: $(BIN).toml 21 | aws s3 cp $^ "s3://$(CONF_S3_BUCKET)/$(CONF_S3_PATH)" 22 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | Rules []Rule `toml:"rule"` 5 | Destinations []Destination `toml:"destination"` 6 | } 7 | 8 | type Rule struct { 9 | Name string `toml:"name"` 10 | JQMatch string `toml:"jq_match"` 11 | Destinations []string `toml:"destinations"` 12 | Desc string `toml:"description"` 13 | } 14 | 15 | type Destination struct { 16 | ID string `toml:"id"` 17 | // Type is a string of "sns" "slack_webhook" "ses" 18 | Type string `toml:"type"` 19 | 20 | // SNSARN is for type "sns" 21 | SNSARN string `toml:"sns_arn"` 22 | 23 | // WebhookURL is for type "slack_webhook" 24 | WebhookURL string `toml:"webhook_url"` 25 | 26 | // ToEmails is for type "ses" 27 | ToEmails []string `toml:"to_emails"` 28 | // FromEmail is for type "ses" 29 | FromEmail string `toml:"from_email"` 30 | } 31 | -------------------------------------------------------------------------------- /example.tattletail.toml: -------------------------------------------------------------------------------- 1 | [[rule]] 2 | name = "Create User" 3 | jq_match = 'select(.eventName == "CreateUser") | "username: \(.responseElements.user.userName)"' 4 | description = "A new IAM user has been created" 5 | destinations = ["Default SNS", "Slack Warnings", "Email"] 6 | 7 | [[rule]] 8 | name = "Create AccessKey" 9 | jq_match = 'select(.eventName == "CreateAccessKey")' 10 | description = "A new Access Key has been created" 11 | destinations = ["Default SNS", "Slack Warnings", "Email"] 12 | 13 | [[destination]] 14 | id = "Default SNS" 15 | type = "sns" 16 | sns_arn = "arn:aws:sns:us-east-1:1234567890:cloudtail_alert" 17 | 18 | [[destination]] 19 | id = "Slack Warnings" 20 | type = "slack_webhook" 21 | webhook_url = "https://foo.slack.com/some/webhook/url" 22 | 23 | [[destination]] 24 | id = "Email" 25 | type = "ses" 26 | to_emails = ["foo@example.com", "bar@example.com"] 27 | from_email = "cloudtrail_alerts@example.com" 28 | -------------------------------------------------------------------------------- /internal/destslack/destslack_test.go: -------------------------------------------------------------------------------- 1 | package destslack 2 | 3 | import "testing" 4 | 5 | func TestString(t *testing.T) { 6 | d := DestSlackWebhook{ 7 | id: "slack webhook", 8 | webhookURL: "https://hooks.slack.com/services/T00000000/B00000000/XXXX_SENSITIVE_URL_XXXX", 9 | } 10 | 11 | actual := d.String() 12 | expected := "{id: slack webhook webhookURL: https://hooks.slack.com/services/T00000000/B00000000/**FILTERED**}" 13 | if actual != expected { 14 | t.Errorf("expecting %s, got %s", expected, actual) 15 | } 16 | } 17 | 18 | func TestStringSimpleURL(t *testing.T) { 19 | d := DestSlackWebhook{ 20 | id: "slack webhook", 21 | webhookURL: "https://hooks.slack.com/services", 22 | } 23 | 24 | actual := d.String() 25 | expected := "{id: slack webhook webhookURL: https://hooks.slack.com/services}" 26 | if actual != expected { 27 | t.Errorf("expecting %s, got %s", expected, actual) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /awsstub/awsstub.go: -------------------------------------------------------------------------------- 1 | package awsstub 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/request" 8 | "github.com/aws/aws-sdk-go/aws/session" 9 | "github.com/aws/aws-sdk-go/service/s3" 10 | "github.com/aws/aws-sdk-go/service/ses" 11 | "github.com/aws/aws-sdk-go/service/sns" 12 | ) 13 | 14 | var ( 15 | S3GetObj func(*s3.GetObjectInput) (*s3.GetObjectOutput, error) 16 | S3GetObjWithContext func(aws.Context, *s3.GetObjectInput, ...request.Option) (*s3.GetObjectOutput, error) 17 | 18 | SnsPublish func(*sns.PublishInput) (*sns.PublishOutput, error) 19 | 20 | SendEmail func(*ses.SendEmailInput) (*ses.SendEmailOutput, error) 21 | ) 22 | 23 | func InitAWS() { 24 | awsSession := session.New(&aws.Config{ 25 | Region: aws.String(os.Getenv("AWS_REGION")), 26 | }) 27 | 28 | s3Client := s3.New(awsSession) 29 | snsClient := sns.New(awsSession) 30 | sesClient := ses.New(awsSession) 31 | 32 | S3GetObj = s3Client.GetObject 33 | S3GetObjWithContext = s3Client.GetObjectWithContext 34 | SnsPublish = snsClient.Publish 35 | 36 | SendEmail = sesClient.SendEmail 37 | 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Peter Sanford 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 | -------------------------------------------------------------------------------- /internal/destsns/destsns.go: -------------------------------------------------------------------------------- 1 | package destsns 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/service/sns" 10 | "github.com/psanford/cloudtrail-tattletail/awsstub" 11 | "github.com/psanford/cloudtrail-tattletail/config" 12 | "github.com/psanford/cloudtrail-tattletail/internal/destination" 13 | ) 14 | 15 | type Loader struct { 16 | } 17 | 18 | func NewLoader() *Loader { 19 | return &Loader{} 20 | } 21 | 22 | func (l *Loader) Type() string { 23 | return "sns" 24 | } 25 | 26 | func (l *Loader) Load(c config.Destination) (destination.Destination, error) { 27 | if c.ID == "" { 28 | return nil, fmt.Errorf("(sns) destination.id must be set") 29 | } 30 | if c.SNSARN == "" { 31 | return nil, fmt.Errorf("(sns) destination.sns_arn must be set for %q", c.ID) 32 | } 33 | 34 | if !strings.HasPrefix(c.SNSARN, "arn:") { 35 | return nil, fmt.Errorf("(sns) destination.sns_arn must be a full ARN beginning with `arn:` for %q", c.ID) 36 | } 37 | 38 | d := DestSNS{ 39 | id: c.ID, 40 | arn: c.SNSARN, 41 | } 42 | return &d, nil 43 | } 44 | 45 | type DestSNS struct { 46 | id string 47 | arn string 48 | } 49 | 50 | func (d *DestSNS) ID() string { 51 | return d.id 52 | } 53 | 54 | func (d *DestSNS) Type() string { 55 | return "sns" 56 | } 57 | 58 | func (d *DestSNS) Send(name, desc string, rec map[string]interface{}, matchObj interface{}) error { 59 | payload := Payload{ 60 | Name: name, 61 | Desc: desc, 62 | Record: rec, 63 | Match: matchObj, 64 | } 65 | 66 | payloadBytes, err := json.Marshal(payload) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | _, err = awsstub.SnsPublish(&sns.PublishInput{ 72 | Message: aws.String(string(payloadBytes)), 73 | TopicArn: &d.arn, 74 | }) 75 | 76 | if err != nil { 77 | return fmt.Errorf("sns publish failure topic_arn=%q err=%w", d.arn, err) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | type Payload struct { 84 | Name string `json:"name"` 85 | Desc string `json:"description"` 86 | Record map[string]interface{} `json:"record"` 87 | Match interface{} `json:"match"` 88 | } 89 | -------------------------------------------------------------------------------- /internal/destses/destses.go: -------------------------------------------------------------------------------- 1 | package destses 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/service/ses" 10 | "github.com/psanford/cloudtrail-tattletail/awsstub" 11 | "github.com/psanford/cloudtrail-tattletail/config" 12 | "github.com/psanford/cloudtrail-tattletail/internal/destination" 13 | ) 14 | 15 | type Loader struct { 16 | } 17 | 18 | func NewLoader() *Loader { 19 | return &Loader{} 20 | } 21 | 22 | var typeName = "ses" 23 | 24 | func (l *Loader) Type() string { 25 | return typeName 26 | } 27 | 28 | func (l *Loader) Load(c config.Destination) (destination.Destination, error) { 29 | if c.ID == "" { 30 | return nil, fmt.Errorf("(ses) destination.id must be set") 31 | } 32 | if len(c.ToEmails) == 0 { 33 | return nil, fmt.Errorf("(ses) destination.to_emails must be set for %q", c.ID) 34 | } 35 | 36 | if c.FromEmail == "" { 37 | return nil, fmt.Errorf("(ses) destination.from_email must be set for %q", c.ID) 38 | } 39 | 40 | d := DestSES{ 41 | id: c.ID, 42 | fromEmail: c.FromEmail, 43 | } 44 | 45 | for _, email := range c.ToEmails { 46 | email := email 47 | d.toEmails = append(d.toEmails, &email) 48 | } 49 | 50 | return &d, nil 51 | } 52 | 53 | type DestSES struct { 54 | id string 55 | toEmails []*string 56 | fromEmail string 57 | } 58 | 59 | func (d *DestSES) ID() string { 60 | return d.id 61 | } 62 | 63 | func (d *DestSES) Type() string { 64 | return typeName 65 | } 66 | 67 | func (d *DestSES) Send(name, desc string, rec map[string]interface{}, matchObj interface{}) error { 68 | 69 | jsonObj, err := json.MarshalIndent(rec, "", " ") 70 | if err != nil { 71 | return fmt.Errorf("marshal obj err: %w", err) 72 | } 73 | 74 | var matchText string 75 | m, ok := matchObj.(map[string]interface{}) 76 | if !ok || !reflect.DeepEqual(rec, m) { 77 | b, err := json.MarshalIndent(matchObj, "", " ") 78 | if err == nil { 79 | matchText = string(b) 80 | } 81 | } 82 | 83 | body := fmt.Sprintf("Alert: %s\n\n%s\n\n\nevent:\n%s\n", name, desc, jsonObj) 84 | if matchText != "" { 85 | body += "match: " + matchText + "\n" 86 | } 87 | 88 | _, err = awsstub.SendEmail(&ses.SendEmailInput{ 89 | Source: &d.fromEmail, 90 | Destination: &ses.Destination{ 91 | ToAddresses: d.toEmails, 92 | }, 93 | Message: &ses.Message{ 94 | Subject: &ses.Content{ 95 | Data: aws.String("Cloudtrail Tattletail event"), 96 | }, 97 | Body: &ses.Body{ 98 | Text: &ses.Content{ 99 | Data: &body, 100 | }, 101 | }, 102 | }, 103 | }) 104 | return err 105 | } 106 | -------------------------------------------------------------------------------- /internal/destslack/destslack.go: -------------------------------------------------------------------------------- 1 | package destslack 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/psanford/cloudtrail-tattletail/config" 10 | "github.com/psanford/cloudtrail-tattletail/internal/destination" 11 | "github.com/slack-go/slack" 12 | ) 13 | 14 | type Loader struct { 15 | } 16 | 17 | func NewLoader() *Loader { 18 | return &Loader{} 19 | } 20 | 21 | var typeName = "slack_webhook" 22 | 23 | func (l *Loader) Type() string { 24 | return typeName 25 | } 26 | 27 | func (l *Loader) Load(c config.Destination) (destination.Destination, error) { 28 | if c.ID == "" { 29 | return nil, fmt.Errorf("(slack_webhook) destination.id must be set") 30 | } 31 | if c.WebhookURL == "" { 32 | return nil, fmt.Errorf("(slack_webhook) destination.webhook_url must be set for %q", c.ID) 33 | } 34 | 35 | d := DestSlackWebhook{ 36 | id: c.ID, 37 | webhookURL: c.WebhookURL, 38 | } 39 | return &d, nil 40 | } 41 | 42 | type DestSlackWebhook struct { 43 | id string 44 | webhookURL string 45 | } 46 | 47 | func (d *DestSlackWebhook) ID() string { 48 | return d.id 49 | } 50 | 51 | func (d *DestSlackWebhook) Type() string { 52 | return typeName 53 | } 54 | 55 | func (d *DestSlackWebhook) Send(name, desc string, rec map[string]interface{}, matchObj interface{}) error { 56 | 57 | jsonObj, err := json.MarshalIndent(rec, "", " ") 58 | if err != nil { 59 | return fmt.Errorf("marshal obj err: %w", err) 60 | } 61 | 62 | var matchTxt string 63 | 64 | m, ok := matchObj.(map[string]interface{}) 65 | if !ok || !reflect.DeepEqual(rec, m) { 66 | b, err := json.MarshalIndent(matchObj, "", " ") 67 | if err == nil { 68 | matchTxt = string(b) 69 | } 70 | } 71 | 72 | msg := slack.WebhookMessage{ 73 | IconEmoji: "red_circle", 74 | Username: "Cloudtrail Tattletail", 75 | Attachments: []slack.Attachment{ 76 | { 77 | Color: "danger", 78 | Title: "Cloudtrail Tattletail Event", 79 | Text: string(jsonObj), 80 | Fields: []slack.AttachmentField{ 81 | { 82 | Title: "Alert Name", 83 | Value: name, 84 | Short: true, 85 | }, 86 | { 87 | Title: "Description", 88 | Value: desc, 89 | Short: true, 90 | }, 91 | }, 92 | }, 93 | }, 94 | } 95 | 96 | if matchTxt != "" { 97 | msg.Attachments[0].Fields = append(msg.Attachments[0].Fields, slack.AttachmentField{ 98 | Title: "Match", 99 | Value: matchTxt, 100 | }) 101 | } 102 | 103 | return slack.PostWebhook(d.webhookURL, &msg) 104 | } 105 | 106 | func (d *DestSlackWebhook) String() string { 107 | paths := strings.Split(d.webhookURL, "/") 108 | if len(paths) > 4 { 109 | paths[len(paths)-1] = "**FILTERED**" 110 | } 111 | 112 | return fmt.Sprintf("{id: %s webhookURL: %s}", d.id, strings.Join(paths, "/")) 113 | } 114 | -------------------------------------------------------------------------------- /testdata/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventVersion": "1.08", 5 | "userIdentity": { 6 | "type": "AssumedRole", 7 | "principalId": "AROAVJ2E6QGRUSOFPZVEM:AWSCLI-Session", 8 | "arn": "arn:aws:sts::123456789:assumed-role/admin_read_write/AWSCLI-Session", 9 | "accountId": "123456789", 10 | "accessKeyId": "ASIAVJ2E6QGRTNFEV3HG", 11 | "sessionContext": { 12 | "sessionIssuer": { 13 | "type": "Role", 14 | "principalId": "AROAVJ2E6QGRUSOFPZVEM", 15 | "arn": "arn:aws:iam::123456789:role/admin_read_write", 16 | "accountId": "123456789", 17 | "userName": "admin_read_write" 18 | }, 19 | "webIdFederationData": {}, 20 | "attributes": { 21 | "mfaAuthenticated": "true", 22 | "creationDate": "2021-07-10T16:01:05Z" 23 | } 24 | } 25 | }, 26 | "eventTime": "2021-07-10T16:41:35Z", 27 | "eventSource": "iam.amazonaws.com", 28 | "eventName": "ListAttachedRolePolicies", 29 | "awsRegion": "us-east-1", 30 | "sourceIPAddress": "1.1.1.1", 31 | "userAgent": "aws-sdk-go/1.35.25 (go1.14.5; linux; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io)", 32 | "requestParameters": { 33 | "roleName": "hn-rss-assume-role" 34 | }, 35 | "responseElements": null, 36 | "requestID": "907b2b6b-15cb-41bb-b92e-64af23c6bfbc", 37 | "eventID": "b788100d-c21b-4575-a09f-5d01483d28b8", 38 | "readOnly": true, 39 | "eventType": "AwsApiCall", 40 | "managementEvent": true, 41 | "eventCategory": "Management", 42 | "recipientAccountId": "123456789" 43 | }, 44 | { 45 | "eventVersion": "1.08", 46 | "userIdentity": { 47 | "type": "AssumedRole", 48 | "principalId": "AROAVJ2E6QGRUSOFPZVEM:AWSCLI-Session", 49 | "arn": "arn:aws:sts::123456789:assumed-role/admin_read_write/AWSCLI-Session", 50 | "accountId": "123456789", 51 | "accessKeyId": "ASIAVJ2E6QGRTNFEV3HG", 52 | "sessionContext": { 53 | "sessionIssuer": { 54 | "type": "Role", 55 | "principalId": "AROAVJ2E6QGRUSOFPZVEM", 56 | "arn": "arn:aws:iam::123456789:role/admin_read_write", 57 | "accountId": "123456789", 58 | "userName": "admin_read_write" 59 | }, 60 | "webIdFederationData": {}, 61 | "attributes": { 62 | "mfaAuthenticated": "true", 63 | "creationDate": "2021-07-10T16:01:05Z" 64 | } 65 | } 66 | }, 67 | "eventTime": "2021-07-10T16:42:10Z", 68 | "eventSource": "iam.amazonaws.com", 69 | "eventName": "CreateUser", 70 | "awsRegion": "us-east-1", 71 | "sourceIPAddress": "1.1.1.1", 72 | "userAgent": "aws-sdk-go/1.35.25 (go1.14.5; linux; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io)", 73 | "requestParameters": { 74 | "path": "/", 75 | "userName": "user1" 76 | }, 77 | "responseElements": { 78 | "user": { 79 | "path": "/", 80 | "userName": "user1", 81 | "userId": "AIDAVJ2E6QGRR7EVG7JBE", 82 | "arn": "arn:aws:iam::123456789:user/user1", 83 | "createDate": "Jul 10, 2021 4:42:10 PM" 84 | } 85 | }, 86 | "requestID": "b9e6cd18-9632-4258-9539-31ecc2569f43", 87 | "eventID": "692d22af-1b8a-4a40-bd87-e290897e9e95", 88 | "readOnly": false, 89 | "eventType": "AwsApiCall", 90 | "managementEvent": true, 91 | "eventCategory": "Management", 92 | "recipientAccountId": "123456789" 93 | }, 94 | { 95 | "eventVersion": "1.08", 96 | "userIdentity": { 97 | "type": "AssumedRole", 98 | "principalId": "AROAVJ2E6QGRUSOFPZVEM:AWSCLI-Session", 99 | "arn": "arn:aws:sts::123456789:assumed-role/admin_read_write/AWSCLI-Session", 100 | "accountId": "123456789", 101 | "accessKeyId": "ASIAVJ2E6QGRTNFEV3HG", 102 | "sessionContext": { 103 | "sessionIssuer": { 104 | "type": "Role", 105 | "principalId": "AROAVJ2E6QGRUSOFPZVEM", 106 | "arn": "arn:aws:iam::123456789:role/admin_read_write", 107 | "accountId": "123456789", 108 | "userName": "admin_read_write" 109 | }, 110 | "webIdFederationData": {}, 111 | "attributes": { 112 | "mfaAuthenticated": "true", 113 | "creationDate": "2021-07-10T16:01:05Z" 114 | } 115 | } 116 | }, 117 | "eventTime": "2021-07-10T16:41:35Z", 118 | "eventSource": "iam.amazonaws.com", 119 | "eventName": "ListAttachedRolePolicies", 120 | "awsRegion": "us-east-1", 121 | "sourceIPAddress": "1.1.1.1", 122 | "userAgent": "aws-sdk-go/1.35.25 (go1.14.5; linux; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io)", 123 | "requestParameters": { 124 | "roleName": "hn-rss-assume-role" 125 | }, 126 | "responseElements": null, 127 | "requestID": "907b2b6b-15cb-41bb-b92e-64af23c6bfbc", 128 | "eventID": "b788100d-c21b-4575-a09f-5d01483d28b8", 129 | "readOnly": true, 130 | "eventType": "AwsApiCall", 131 | "managementEvent": true, 132 | "eventCategory": "Management", 133 | "recipientAccountId": "123456789" 134 | } 135 | ] 136 | } 137 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/aws/aws-lambda-go v1.32.0 h1:i8MflawW1hoyYp85GMH7LhvAs4cqzL7LOS6fSv8l2KM= 4 | github.com/aws/aws-lambda-go v1.32.0/go.mod h1:IF5Q7wj4VyZyUFnZ54IQqeWtctHQ9tz+KhcbDenr220= 5 | github.com/aws/aws-sdk-go v1.39.4 h1:nXBChUaG5cinrl3yg4/rUyssOOLH/ohk4S9K03kJirE= 6 | github.com/aws/aws-sdk-go v1.39.4/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 11 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 12 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 13 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 14 | github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= 15 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 16 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 17 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 18 | github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac h1:n1DqxAo4oWPMvH1+v+DLYlMCecgumhhgnxAPdqDIFHI= 19 | github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= 20 | github.com/itchyny/go-flags v1.5.0/go.mod h1:lenkYuCobuxLBAd/HGFE4LRoW8D3B6iXRQfWYJ+MNbA= 21 | github.com/itchyny/gojq v0.12.4 h1:8zgOZWMejEWCLjbF/1mWY7hY7QEARm7dtuhC6Bp4R8o= 22 | github.com/itchyny/gojq v0.12.4/go.mod h1:EQUSKgW/YaOxmXpAwGiowFDO4i2Rmtk5+9dFyeiymAg= 23 | github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU= 24 | github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A= 25 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 26 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 27 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 28 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 29 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= 30 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 31 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 32 | github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= 33 | github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 34 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 35 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 36 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 37 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 38 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 39 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 40 | github.com/slack-go/slack v0.9.1 h1:pekQBs0RmrdAgoqzcMCzUCWSyIkhzUU3F83ExAdZrKo= 41 | github.com/slack-go/slack v0.9.1/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= 42 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 43 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 44 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 45 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 46 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= 47 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 48 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 49 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 51 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b h1:qh4f65QIVFjq9eBURLEYWqaEXmOyqdUyiBSgaXWccWk= 54 | golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 56 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 57 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 58 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 59 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 60 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 63 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 64 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 65 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 66 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 67 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 68 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Cloudtail-Tattletail 2 | 3 | Cloudtrail-Tattletail is a Lambda based Cloudtrail alerting tool. It allows you to write simple rules for interesting Cloudtrail events and forward those events to a number of different systems. 4 | 5 | Cloudtrail-Tattletail is designed to run with a minimal set of dependencies to make it easy to get up and alerting without needing to setup a lot of different AWS services. The only hard requirement is that you enable S3 events to trigger the Tattletail lambda function from the S3 bucket that your Cloudtrail logs are going to. 6 | 7 | Currently Cloudtrail-Tattletail supports the following destinations to forward alerts to: 8 | - SNS Topic 9 | - Email Address (via SES) 10 | - Slack Channel (via slack_webhook) 11 | 12 | Forwarding to an SNS Topic allows for easy extensibility. 13 | 14 | ## Configuration 15 | 16 | There are two basic things you need to configure to start using Cloudtrail-Tattletail: Rules and Destinations. 17 | 18 | Rules describe which events should be forwarded and alerted on. Rules are written in the `jq` [query language](https://stedolan.github.io/jq/manual/). This makes it easy to write both simple and complex matching logic. 19 | 20 | Each rule that matches an event will be forwarded to all the destinations listed in the rule. 21 | 22 | Destinations are the upstream service that the alert should be forwarded to. 23 | 24 | ### Setup 25 | 26 | 1. Create a configuration file with your alert rules and destinations. 27 | 1. Enable cloudtrail logging to an S3 bucket. 28 | 1. Create a Go lambda function 29 | 1. Grant the lambda function access to the cloudtrail s3 bucket 30 | 1. Add an s3 trigger to invoke the lambda function for new files 31 | 1. Add permissions for SNS and SES if you are using those destinations 32 | 33 | #### Configuration example 34 | 35 | ``` 36 | [[rule]] 37 | name = "Create User" 38 | jq_match = ''' 39 | select(.eventName == "CreateUser") | 40 | "username: \(.responseElements.user.userName)" 41 | ''' 42 | description = "A new IAM user has been created" 43 | destinations = ["Default SNS", "Slack Warnings", "Email"] 44 | 45 | [[rule]] 46 | name = "Create AccessKey" 47 | jq_match = 'select(.eventName == "CreateAccessKey")' 48 | description = "A new Access Key has been created" 49 | destinations = ["Default SNS", "Slack Warnings", "Email"] 50 | 51 | [[rule]] 52 | name = "Modifications" 53 | # match any event that doesn't begin with List,Get,Describe,etc. 54 | jq_match = 'select(.eventName|test("^(List|Get|Describe|AssumeRole|Decrypt|CreateLog|ConsoleLogin)")|not)' 55 | description = 'A config change occurred' 56 | # just send this to Slack 57 | destinations = ["Slack Warnings"] 58 | 59 | [[destination]] 60 | id = "Default SNS" 61 | type = "sns" 62 | sns_arn = "arn:aws:sns:us-east-1:1234567890:cloudtail_alert" 63 | 64 | [[destination]] 65 | id = "Slack Warnings" 66 | type = "slack_webhook" 67 | webhook_url = "https://foo.slack.com/some/webhook/url" 68 | 69 | [[destination]] 70 | id = "Email" 71 | type = "ses" 72 | to_emails = ["foo@example.com", "bar@example.com"] 73 | from_email = "cloudtrail_alerts@example.com" 74 | ``` 75 | 76 | The configuration file can either be bundled directly in lambda function, or it can be uploaded to an S3 bucket and the lambda function will fetch it when it is invoked. Bundling the configuration file directly is simpler but you have to reupload the whole lambda function any time you want to make configuration changes. 77 | 78 | To include the configuration file directly in the lambda function simply create a file named `tattletail.toml` in the cloudtrail-tattletail working directory. Running `make cloudtrail-tattletail.zip` will include the configuration in the zip bundle file if it is present. 79 | 80 | To load the configuration from an S3 bucket set the following environment variables on the Lambda function `S3_CONFIG_BUCKET` and `S3_CONFIG_PATH`. Make sure the Lambda function has permission to GetObject for that bucket + path. 81 | 82 | If the s3 config environment variables are set they will take precedence over any bundled config file. 83 | 84 | #### Lambda Function 85 | 86 | To build the Lambda function code bundle run `make cloudtrail-tattletail.zip`. 87 | 88 | Create a Go Lambda function. Upload the `cloudtrail-tattletail.zip` file as the code bundle. 89 | 90 | Add an s3 trigger from your CloudTrail s3 bucket. 91 | 92 | Add any permissions for invoking SNS or SES if you are using those destination types. 93 | 94 | # Writing jq_match queries 95 | 96 | Each cloud trail event is tested against `jq_match` individually. This means your jq should not include a top level `.records[]`. If you want 97 | to test your `jq_match` query against a cloudtrail log you can do it like this: 98 | 99 | ``` 100 | # assuming your jq_match = 'select(.eventName == "CreateAccessKey")' 101 | 102 | # get the stream of cloudtrail events: 103 | $ jq '.Records[]' 364679954851_CloudTrail_us-east-1_20210713T1540Z_DWktkdMpEvhK04Eq.json > cloudtrail.stream.json 104 | 105 | # run the jq_match query against the stream file 106 | $ jq 'select(.eventName == "CreateAccessKey")' cloudtrail.stream.json 107 | { 108 | "eventVersion": "1.08", 109 | "userIdentity": { 110 | "type": "AssumedRole", 111 | "arn": "arn:aws:sts::123456789:assumed-role/admin_read_write/AWSCLI-Session", 112 | "accountId": "123456789", 113 | "sessionContext": { 114 | "sessionIssuer": { 115 | "type": "Role", 116 | "arn": "arn:aws:iam::123456789:role/admin", 117 | "accountId": "123456789", 118 | "userName": "admin" 119 | }, 120 | "webIdFederationData": {}, 121 | "attributes": { 122 | "mfaAuthenticated": "true", 123 | "creationDate": "2021-07-13T14:59:19Z" 124 | } 125 | } 126 | }, 127 | "eventTime": "2021-07-13T15:30:43Z", 128 | "eventSource": "iam.amazonaws.com", 129 | "eventName": "CreateAccessKey", 130 | "awsRegion": "us-east-1", 131 | "sourceIPAddress": "1.1.1.1", 132 | "userAgent": "console.amazonaws.com", 133 | "requestParameters": { 134 | "userName": "nopermuser" 135 | }, 136 | "responseElements": { 137 | "accessKey": { 138 | "userName": "nopermuser", 139 | "status": "Active", 140 | "createDate": "Jul 13, 2021 3:30:43 PM" 141 | } 142 | }, 143 | "requestID": "c6325cf0-ea6e-48ea-b2bd-2df2630ce790", 144 | "eventID": "7f234c0f-61d9-4d9e-add6-f767474d9be6", 145 | "readOnly": false, 146 | "eventType": "AwsApiCall", 147 | "managementEvent": true, 148 | "eventCategory": "Management", 149 | "recipientAccountId": "123456789" 150 | } 151 | ``` 152 | 153 | Cloudtrail Tattletail will only alert on queries that return a non-null, non-false value. 154 | 155 | ### Advanced jq_matching 156 | 157 | Cloudtrail Tattletail will annotate alerts with custom formatted output of any jq expression that evaluates to something other than the full cloudtrail record. For example. to include the username of a `CreateUser` event as the annotation you could use the following `jq_match`: 158 | 159 | ``` 160 | jq_match = 'select(.eventName == "CreateUser") | "username: \(.responseElements.user.userName)"' 161 | 162 | # this outputs: 163 | "username: hacker1" 164 | ``` 165 | 166 | If you do not want to include any match metadata with the alerts use `select()`: 167 | 168 | ``` 169 | jq_match = 'select(.eventName == "CreateUser") 170 | ``` 171 | 172 | # Screenshots 173 | 174 | ### Slack Webhook 175 | Slack Webhook 176 | 177 | ### Email (SES) 178 | Email 179 | -------------------------------------------------------------------------------- /cloudtrail_tattletail.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | 12 | "github.com/BurntSushi/toml" 13 | "github.com/aws/aws-lambda-go/events" 14 | "github.com/aws/aws-lambda-go/lambda" 15 | "github.com/aws/aws-sdk-go/aws" 16 | "github.com/aws/aws-sdk-go/aws/request" 17 | "github.com/aws/aws-sdk-go/service/s3" 18 | "github.com/inconshreveable/log15" 19 | "github.com/itchyny/gojq" 20 | "github.com/psanford/cloudtrail-tattletail/awsstub" 21 | "github.com/psanford/cloudtrail-tattletail/config" 22 | "github.com/psanford/cloudtrail-tattletail/internal/destination" 23 | "github.com/psanford/cloudtrail-tattletail/internal/destses" 24 | "github.com/psanford/cloudtrail-tattletail/internal/destslack" 25 | "github.com/psanford/cloudtrail-tattletail/internal/destsns" 26 | ) 27 | 28 | func main() { 29 | awsstub.InitAWS() 30 | handler := log15.StreamHandler(os.Stdout, log15.LogfmtFormat()) 31 | log15.Root().SetHandler(handler) 32 | s := newServer() 33 | lambda.Start(s.Handler) 34 | } 35 | 36 | func newServer() *server { 37 | loaders := []destination.Loader{ 38 | destsns.NewLoader(), 39 | destses.NewLoader(), 40 | destslack.NewLoader(), 41 | } 42 | 43 | s := server{ 44 | loaders: make(map[string]destination.Loader), 45 | } 46 | for _, l := range loaders { 47 | s.loaders[l.Type()] = l 48 | } 49 | return &s 50 | } 51 | 52 | type server struct { 53 | loaders map[string]destination.Loader 54 | 55 | rules []Rule 56 | } 57 | 58 | func (s *server) Handler(evt events.S3Event) error { 59 | lgr := log15.New() 60 | 61 | err := s.loadConfig(lgr) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | for _, rec := range evt.Records { 67 | err := s.handleRecord(lgr, rec) 68 | if err != nil { 69 | return err 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func (s *server) loadConfig(lgr log15.Logger) error { 77 | // if S3_CONFIG_BUCKET and S3_CONFIG_PATH are set, load config file from s3 78 | bucketName := os.Getenv("S3_CONFIG_BUCKET") 79 | confPath := os.Getenv("S3_CONFIG_PATH") 80 | 81 | var confReader io.Reader 82 | 83 | if bucketName != "" && confPath != "" { 84 | lgr = lgr.New("conf_src", "s3", "bucket", bucketName, "path", confPath) 85 | confResp, err := awsstub.S3GetObj(&s3.GetObjectInput{ 86 | Bucket: aws.String(bucketName), 87 | Key: aws.String(confPath), 88 | }) 89 | 90 | if err != nil { 91 | lgr.Error("load_config_from_s3_err", "err", err) 92 | return err 93 | } 94 | defer confResp.Body.Close() 95 | 96 | confReader = confResp.Body 97 | } else { 98 | fname := "tattletail.toml" 99 | lgr = lgr.New("conf_src", "local_bundle", "filename", fname) 100 | f, err := os.Open(fname) 101 | if err != nil { 102 | lgr.Error("open_bundled_config_file_err", "err", err) 103 | return err 104 | } 105 | defer f.Close() 106 | confReader = f 107 | } 108 | 109 | var conf config.Config 110 | _, err := toml.DecodeReader(confReader, &conf) 111 | if err != nil { 112 | lgr.Error("config_toml_parse_err", "err", err) 113 | return err 114 | } 115 | 116 | destinations := make(map[string]destination.Destination) 117 | 118 | for _, dest := range conf.Destinations { 119 | loader := s.loaders[dest.Type] 120 | if loader == nil { 121 | lgr.Error("invalid_destination_type", "id", dest.ID, "invalid_type", dest.Type) 122 | return fmt.Errorf("invalid destination type %q for destination %q", dest.Type, dest.ID) 123 | } 124 | 125 | d, err := loader.Load(dest) 126 | if err != nil { 127 | lgr.Error("invalid_destination_config", "err", err) 128 | return err 129 | } 130 | 131 | if _, exists := destinations[d.ID()]; exists { 132 | lgr.Error("duplicate_destinations_with_same_id", "id", d.ID()) 133 | return fmt.Errorf("duplicate destinations with same id: %q", d.ID()) 134 | } 135 | 136 | destinations[d.ID()] = d 137 | } 138 | 139 | s.rules = make([]Rule, 0, len(conf.Rules)) 140 | 141 | for i, rule := range conf.Rules { 142 | r := Rule{ 143 | name: rule.Name, 144 | desc: rule.Desc, 145 | } 146 | if rule.JQMatch == "" { 147 | lgr.Error("jq_match_not_defined_for_rule", "rule_name", rule.Name, "rule_idx", i) 148 | return fmt.Errorf("jq_match not defined for rule name=%q idx=%d", rule.Name, i) 149 | } 150 | q, err := gojq.Parse(rule.JQMatch) 151 | if err != nil { 152 | return fmt.Errorf("parse jq_match err for rule name=%q idx=%d query=%q err=%w", rule.Name, i, rule.JQMatch, err) 153 | } 154 | r.query = q 155 | 156 | for _, destName := range rule.Destinations { 157 | dest := destinations[destName] 158 | if dest == nil { 159 | return fmt.Errorf("unknown destination %q for rule name=%q idx=%d", destName, r.name, i) 160 | } 161 | r.dests = append(r.dests, dest) 162 | } 163 | 164 | s.rules = append(s.rules, r) 165 | lgr.Info("loaded_rule", "name", r.name) 166 | } 167 | 168 | return nil 169 | } 170 | 171 | func (s *server) handleRecord(lgr log15.Logger, s3rec events.S3EventRecord) error { 172 | bucket := s3rec.S3.Bucket.Name 173 | file := s3rec.S3.Object.Key 174 | 175 | lgr = lgr.New("cloudtrail_file", file) 176 | lgr.Info("process_file") 177 | 178 | getInput := s3.GetObjectInput{ 179 | Bucket: &bucket, 180 | Key: &file, 181 | } 182 | 183 | dontAutoInflate := func(r *request.Request) { 184 | r.HTTPRequest.Header.Add("Accept-Encoding", "gzip") 185 | } 186 | 187 | resp, err := awsstub.S3GetObjWithContext(context.Background(), &getInput, dontAutoInflate) 188 | if err != nil { 189 | lgr.Error("s3_fetch_err", "err", err) 190 | return err 191 | } 192 | defer resp.Body.Close() 193 | 194 | r, err := gzip.NewReader(resp.Body) 195 | if err != nil { 196 | lgr.Error("new_gz_reader_err", "err", err) 197 | return err 198 | } 199 | body, err := ioutil.ReadAll(r) 200 | if err != nil { 201 | lgr.Error("read_body_err", "err", err) 202 | return err 203 | } 204 | 205 | var doc struct { 206 | Records []map[string]interface{} `json:"records"` 207 | } 208 | 209 | err = json.Unmarshal(body, &doc) 210 | if err != nil { 211 | lgr.Error("decode_json_err", "err", err) 212 | return err 213 | } 214 | 215 | var matchCount int 216 | 217 | for _, rec := range doc.Records { 218 | for _, rule := range s.rules { 219 | var evtID string 220 | idI, ok := rec["eventID"] 221 | if ok { 222 | evtID, _ = idI.(string) 223 | } 224 | if match, obj := rule.Match(lgr, rec); match { 225 | matchCount++ 226 | lgr.Info("rule_matched", "rule_name", rule.name, "evt_id", evtID) 227 | for _, dest := range rule.dests { 228 | lgr.Info("publish_alert", "dest", dest, "rule_name", rule.name, "evt_id", evtID) 229 | err = dest.Send(rule.name, rule.desc, rec, obj) 230 | if err != nil { 231 | lgr.Error("publish_alert_err", "err", err, "type", dest.Type(), "rule_name", rule.name, "evt_id", evtID) 232 | } 233 | } 234 | } 235 | } 236 | } 237 | 238 | lgr.Info("processing_complete", "record_count", len(doc.Records), "match_count", matchCount) 239 | 240 | return nil 241 | } 242 | 243 | type Rule struct { 244 | name string 245 | desc string 246 | query *gojq.Query 247 | transform *gojq.Query 248 | dests []destination.Destination 249 | } 250 | 251 | func (r *Rule) Match(lgr log15.Logger, rec map[string]interface{}) (bool, interface{}) { 252 | iter := r.query.Run(rec) 253 | v, ok := iter.Next() 254 | if !ok { 255 | return false, nil 256 | } 257 | if err, ok := v.(error); ok { 258 | lgr.Error("match_err", "err", err, "rule_name", r.name, "obj", rec) 259 | return false, "" 260 | } 261 | 262 | if v == nil { 263 | return false, nil 264 | } 265 | 266 | if bval, ok := v.(bool); ok { 267 | // if the query evaluates to a bool, we say we match if it is true 268 | return bval, bval 269 | } 270 | 271 | return true, v 272 | } 273 | -------------------------------------------------------------------------------- /cloudtrail_tattletail_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "crypto/rand" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "net/http/httptest" 13 | "os" 14 | "testing" 15 | 16 | "github.com/aws/aws-lambda-go/events" 17 | "github.com/aws/aws-sdk-go/aws" 18 | "github.com/aws/aws-sdk-go/aws/awserr" 19 | "github.com/aws/aws-sdk-go/aws/request" 20 | "github.com/aws/aws-sdk-go/service/s3" 21 | "github.com/aws/aws-sdk-go/service/s3/s3manager" 22 | "github.com/aws/aws-sdk-go/service/ses" 23 | "github.com/aws/aws-sdk-go/service/sns" 24 | "github.com/google/go-cmp/cmp" 25 | "github.com/inconshreveable/log15" 26 | "github.com/psanford/cloudtrail-tattletail/awsstub" 27 | "github.com/psanford/cloudtrail-tattletail/internal/destsns" 28 | ) 29 | 30 | var ( 31 | fakeS3 = make(map[bucketKey][]byte) 32 | snsMessages []destsns.Payload 33 | sentEmails []sentEmail 34 | ) 35 | 36 | func TestRuleMatchingForwarding(t *testing.T) { 37 | awsstub.S3GetObj = fakeGetObj 38 | awsstub.S3GetObjWithContext = fakeGetObjWithContext 39 | awsstub.SnsPublish = fakeSNSPublish 40 | awsstub.SendEmail = fakeSendEmail 41 | 42 | log15.Root().SetHandler(log15.DiscardHandler()) 43 | 44 | jsonTxt, err := ioutil.ReadFile("testdata/1.json") 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | var buf bytes.Buffer 49 | w := gzip.NewWriter(&buf) 50 | 51 | _, err = io.Copy(w, bytes.NewReader(jsonTxt)) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | err = w.Close() 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | bucketName := "interlinked-buzzards" 62 | fileName := "supplants-streptococcal.json.gz" 63 | 64 | _, err = fakePutObj(&s3manager.UploadInput{ 65 | Body: &buf, 66 | Key: &fileName, 67 | Bucket: &bucketName, 68 | }) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | 73 | var webhookPayloads []string 74 | 75 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 76 | body, err := ioutil.ReadAll(r.Body) 77 | if err != nil { 78 | http.Error(w, "Read err", 500) 79 | return 80 | } 81 | webhookPayloads = append(webhookPayloads, string(body)) 82 | }) 83 | 84 | fakeSlack := httptest.NewServer(handler) 85 | defer fakeSlack.Close() 86 | 87 | configTmpl := ` 88 | [[rule]] 89 | name = "Create User" 90 | jq_match = ''' 91 | select(.eventName == "CreateUser") | 92 | "username: \(.responseElements.user.userName)" 93 | ''' 94 | 95 | destinations = ["Default SNS", "Default Slack", "Default SES Email"] 96 | description = "A new IAM user has been created" 97 | 98 | [[destination]] 99 | id = "Default SNS" 100 | type = "sns" 101 | sns_arn = "arn:aws:sns:us-east-1:1234567890:cloudtail_alert" 102 | 103 | [[destination]] 104 | id = "Default Slack" 105 | type = "slack_webhook" 106 | webhook_url = "%s" 107 | 108 | [[destination]] 109 | id = "Default SES Email" 110 | type = "ses" 111 | to_emails = ["foo@example.com", "bar@example.com"] 112 | from_email = "cloudtrail_tattletail@example.com" 113 | ` 114 | config := fmt.Sprintf(configTmpl, fakeSlack.URL) 115 | 116 | confBucket := "mandrake-Aquarius" 117 | confKey := "horseplay-shoveling.toml" 118 | _, err = fakePutObj(&s3manager.UploadInput{ 119 | Body: bytes.NewBufferString(config), 120 | Key: &confKey, 121 | Bucket: &confBucket, 122 | }) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | 127 | os.Setenv("S3_CONFIG_BUCKET", confBucket) 128 | os.Setenv("S3_CONFIG_PATH", confKey) 129 | os.Setenv("AWS_REGION", "us-east-1") 130 | 131 | server := newServer() 132 | evt := events.S3Event{ 133 | Records: []events.S3EventRecord{ 134 | { 135 | S3: events.S3Entity{ 136 | Bucket: events.S3Bucket{ 137 | Name: bucketName, 138 | }, 139 | Object: events.S3Object{ 140 | Key: fileName, 141 | }, 142 | }, 143 | }, 144 | }, 145 | } 146 | 147 | // run twice to make sure we arn't doing anything wrong with double loading config 148 | for i := 0; i < 2; i++ { 149 | snsMessages = snsMessages[:0] 150 | sentEmails = sentEmails[:0] 151 | webhookPayloads = webhookPayloads[:0] 152 | 153 | err = server.Handler(evt) 154 | if err != nil { 155 | t.Fatal(err) 156 | } 157 | } 158 | 159 | var doc struct { 160 | Records []map[string]interface{} `json:"records"` 161 | } 162 | err = json.Unmarshal(jsonTxt, &doc) 163 | if err != nil { 164 | t.Fatal(err) 165 | } 166 | 167 | expect := []destsns.Payload{ 168 | { 169 | Name: "Create User", 170 | Desc: "A new IAM user has been created", 171 | Record: doc.Records[1], 172 | Match: "username: user1", 173 | }, 174 | } 175 | 176 | if !cmp.Equal(snsMessages, expect) { 177 | t.Fatal(cmp.Diff(snsMessages, expect)) 178 | } 179 | 180 | if len(webhookPayloads) != 1 { 181 | t.Fatalf("expected 1 webhook but got %d", len(webhookPayloads)) 182 | } 183 | 184 | var webhookMsg slackMsg 185 | err = json.Unmarshal([]byte(webhookPayloads[0]), &webhookMsg) 186 | if err != nil { 187 | t.Fatal(err) 188 | } 189 | 190 | expectWebhook := slackMsg{ 191 | IconEmoji: "red_circle", 192 | Username: "Cloudtrail Tattletail", 193 | Attachments: []slackAttachment{ 194 | { 195 | Color: "danger", 196 | Title: "Cloudtrail Tattletail Event", 197 | Text: webhookMsg.Attachments[0].Text, 198 | Fields: []slackField{ 199 | { 200 | Title: "Alert Name", 201 | Value: "Create User", 202 | Short: true, 203 | }, 204 | { 205 | Title: "Description", 206 | Value: "A new IAM user has been created", 207 | Short: true, 208 | }, 209 | { 210 | Title: "Match", 211 | Value: `"username: user1"`, 212 | Short: false, 213 | }, 214 | }, 215 | }, 216 | }, 217 | } 218 | 219 | if !cmp.Equal(webhookMsg, expectWebhook) { 220 | t.Fatal(cmp.Diff(webhookMsg, expectWebhook)) 221 | } 222 | 223 | if len(sentEmails) != 1 { 224 | t.Fatalf("Expected 1 send email but got %d", len(sentEmails)) 225 | } 226 | } 227 | 228 | func fakeGetObj(i *s3.GetObjectInput) (*s3.GetObjectOutput, error) { 229 | key := bucketKey{*i.Bucket, *i.Key} 230 | if obj, found := fakeS3[key]; found { 231 | out := &s3.GetObjectOutput{ 232 | Body: ioutil.NopCloser(bytes.NewReader(obj)), 233 | } 234 | return out, nil 235 | } 236 | 237 | return nil, awserr.New(s3.ErrCodeNoSuchKey, s3.ErrCodeNoSuchKey, nil) 238 | } 239 | 240 | func fakeGetObjWithContext(ctx aws.Context, i *s3.GetObjectInput, opts ...request.Option) (*s3.GetObjectOutput, error) { 241 | return fakeGetObj(i) 242 | } 243 | 244 | func fakePutObj(i *s3manager.UploadInput, o ...func(*s3manager.Uploader)) (*s3manager.UploadOutput, error) { 245 | b, err := ioutil.ReadAll(i.Body) 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | key := bucketKey{*i.Bucket, *i.Key} 251 | fakeS3[key] = b 252 | 253 | return &s3manager.UploadOutput{}, nil 254 | } 255 | 256 | type bucketKey struct { 257 | bucket string 258 | key string 259 | } 260 | 261 | func fakeSNSPublish(i *sns.PublishInput) (*sns.PublishOutput, error) { 262 | var msg destsns.Payload 263 | 264 | err := json.Unmarshal([]byte(*i.Message), &msg) 265 | if err != nil { 266 | panic(err) 267 | } 268 | 269 | snsMessages = append(snsMessages, msg) 270 | return nil, nil 271 | } 272 | 273 | func fakeSendEmail(i *ses.SendEmailInput) (*ses.SendEmailOutput, error) { 274 | 275 | id := make([]byte, 32) 276 | _, err := io.ReadFull(rand.Reader, id) 277 | if err != nil { 278 | panic(err) 279 | } 280 | 281 | idStr := fmt.Sprintf("%x", id) 282 | sent := sentEmail{ 283 | input: i, 284 | sendID: idStr, 285 | } 286 | 287 | sentEmails = append(sentEmails, sent) 288 | 289 | return &ses.SendEmailOutput{MessageId: &idStr}, nil 290 | } 291 | 292 | type slackMsg struct { 293 | IconEmoji string `json:"icon_emoji"` 294 | Username string `json:"username"` 295 | Attachments []slackAttachment `json:"attachments"` 296 | } 297 | 298 | type slackAttachment struct { 299 | Color string `json:"color"` 300 | Title string `json:"title"` 301 | Text string `json:"text"` 302 | Fields []slackField `json:"fields"` 303 | } 304 | 305 | type slackField struct { 306 | Title string `json:"title"` 307 | Value string `json:"value"` 308 | Short bool `json:"short"` 309 | } 310 | 311 | type sentEmail struct { 312 | input *ses.SendEmailInput 313 | sendID string 314 | } 315 | --------------------------------------------------------------------------------