├── 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 |
176 |
177 | ### Email (SES)
178 |
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 |
--------------------------------------------------------------------------------