My Note

自己理解のためのブログ

GuardDutyの結果をLambda ( Go ) でSlackに通知する

はじめに

AWSのセキュリティ対策のひとつにGuardDutyというサービスがあります。

aws.amazon.com

  • GuardDuty はインテリジェントな脅威検出サービス。
  • AWS アカウントとワークロードを継続的にモニターし、保護
  • 実際には以下のイベントを分析
    • AWS CloudTrail (アカウント内の AWS ユーザーAPI アクティビティ)
    • VPC フローログ (ネットワークトラフィックデータ)
    • DNS ログ (名前のクエリパターン)
  • 脅威検出は、以下のアクティビティを特定
    • アカウントの侵害
    • インスタンスの侵害、
    • 悪意のある偵察に関連する可能性
  • 以下を検知
    • 異常なAPI コール
    • 既知の悪質な IP アドレスへの不審なアウトバウンド通信
    • DNSクエリを転送メカニズムとして使用の可能性のあるデータの窃盗

現時点では30日間の無料トライアルがあるので、一度有効にしてみて、1ヶ月でどれくらいコストがかかるのかを確認することができます。

今回はこの脅威検出サービスで検知した結果を少しカスタマイズしてSlackに通知させます。

Slack通知を設定

アーキテクチャ

f:id:yhidetoshi:20190829074848p:plain

通知結果

今回は、GuardDutyにサンプル生成した結果をSlack通知させました。

docs.aws.amazon.com

  • 高 ( GetFindingsの結果 severity パラメータの値は 7.0〜8.9 の範囲 )
  • 中 ( GetFindingsの結果 severity パラメータの値は 4.0〜6.9 の範囲 )
  • 低 ( GetFindingsの結果 severity パラメータの値は 0.1〜3.9 )
  • ※ ) 値 0 と 9.0 から 10.0 が将来使用するために現在予約されています。

このseverity の値 ( 高・中・低 ) によってSlack通知の色分けをしました。

api.slack.com

今回、以下のように脅威度によって配色しました。

  • color

    • 高: danger
    • 中: warning
    • 低: #0000ff
  • slack通知結果

f:id:yhidetoshi:20190905080248p:plain

CloudWatchの設定

  1. イベントバターン
  2. サービス名: GuardDuty
  3. イベントタイプ: GuardDuty Finding
  4. ターゲット: Lambda関数を選択して関数名を指定

serverlessFrameworkでデプロイしていますが、コンソール画面での設定ものせておきます。

f:id:yhidetoshi:20190831141418p:plain

Lambda関数

serverlessFrameworkでデプロイします。ソースコードはのちほど記載します。 通知のjsonをパースしてSlackに通知するGoのコードは以下の通りです。

cloudwatchに送信されたGuardDutyのjsonをパースするために調べて必要なところだけ加工したjsonが以下です。

  • json全体をログ出力。
    err := json.Unmarshal([]byte(event.Detail), gd)
    fmt.Println(string([]byte(event.Detail)))
    if err != nil {
        fmt.Println(err)
    }
  • CloudWatchLogsでログを確認 (必要なところだけ抽出)
{
    "schemaVersion": "2.0",
    "accountId": "XXX",
    "region": "ap-northeast-1",
    "type": "UnauthorizedAccess:EC2/TorRelay",
    "resource": {
        "resourceType": "Instance",
        "instanceDetails": {
            "instanceId": "i-99999999",
            "instanceType": "m3.xlarge"
        }
    },
    "title": "EC2 instance i-99999999 is communicating with Tor Exit node.",
    "description": "EC2 instance i-99999999 is communicating with IP address 198.51.100.0 on the Tor Anonymizing Proxy network."
}
{
    "schemaVersion": "2.0",
    "accountId": "XXX",
    "region": "ap-northeast-1",
    "type": "UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration",
    "resource": {
        "resourceType": "AccessKey",
        "accessKeyDetails": {
            "accessKeyId": "GeneratedFindingAccessKeyId",
            "principalId": "GeneratedFindingPrincipalId",
            "userType": "IAMUser",
            "userName": "GeneratedFindingUserName"
        }
    },
    "severity": 8,
    "title": "Credentials for instance role GeneratedFindingUserName used from external IP address.",
    "description": "Credentials created exclusively for an EC2 instance using instance role GeneratedFindingUserName have been used from external IP address 198.51.100.0."
}

これをもとにgoのコードを作成。

  • main.go
package main

import (
    "encoding/json"
    "fmt"
    "os"
    "strconv"
    "strings"

    "github.com/ashwanthkumar/slack-go-webhook"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/iam"
)

const (
    version = "0.0.1"
    region  = "ap-northeast-1"
)

var (
    // USERNAME username of slack
    USERNAME = "GuardDutyAlert"

    // SLACKURL webhookurl of slack
    SLACKURL = os.Getenv("SLACKURL")

    notPostThreshold = os.Getenv("THRESHOLD")

    // add excluded account name: []string{"dev"}
    excludeAccountList = []string{"dev"}
    config             = aws.Config{Region: aws.String(region)}
    svcIAM             = iam.New(session.New(&config))
)

// GuardDutyFindings set guardduty GuardDutyFindingsValue
type GuardDutyFindings struct {
    AccountID   string      `json:"accountId"`
    Region      string      `json:"region"`
    Type        string      `json:"type"`
    Severity    json.Number `json:"severity"`
    Title       string      `json:"title"`
    Description string      `json:"description"`
    Resource    Resource    `json:"resource"`
}

// Resource set guardduty ResourceValue
type Resource struct {
    ResourceType     string           `json:"resourceType,omitempty"`
    UserName         string           `json:"userName,omitempty"`
    InstanceDetails  InstanceDetails  `json:"instanceDetails,omitempty"`
    AccessKeyDetails AccessKeyDetails `json:"accessKeyDetails,omitempty"`
}

// InstanceDetails set guardduty value
type InstanceDetails struct {
    InstanceID   string `json:"instanceId,omitempty"`
    InstanceType string `json:"instanceType,"`
}

// AccessKeyDetails set guardduty AccessKeyDetailsValue
type AccessKeyDetails struct {
    UserName string `json:"userName,omitempty"`
}

func main() {
    lambda.Start(Handler)
}

// Handler get value from cloudwatch event
func Handler(event events.CloudWatchEvent) (events.CloudWatchEvent, error) {
    var resource string
    postFlag := true
    gd := &GuardDutyFindings{}

    err := json.Unmarshal([]byte(event.Detail), gd)
    if err != nil {
        fmt.Println(err)
    }

    // cast to float64
    float64Severity, err := gd.Severity.Float64()
    slackColor := CheckSeverityLevel(float64Severity)
    float64NotPostThreshold, err := strconv.ParseFloat(notPostThreshold, 64)

    // get aws account name
    accountAliasName := FetchAccountAlias()

    // Check excluded List
    for i := range excludeAccountList {
        if strings.Contains(accountAliasName, excludeAccountList[i]) {
            postFlag = false
        }
    }

    // Set affected resource
    if gd.Resource.InstanceDetails.InstanceID != "" {
        resource = gd.Resource.InstanceDetails.InstanceID
    } else if gd.Resource.AccessKeyDetails.UserName != "" {
        resource = gd.Resource.AccessKeyDetails.UserName
    } else {
        resource = "unknown"
    }

    // Post slack
    if postFlag == false && float64Severity < float64NotPostThreshold {
        fmt.Println("Do not post slack")
    } else {
        PostSlack(slackColor, gd.Title, accountAliasName, string(gd.Severity), resource, gd.Type, gd.Description)
    }
    return event, err
}

// CheckSeverityLevel fix the color
func CheckSeverityLevel(severity float64) string {
    var color string

    if severity == 0.0 {
        color = "good"
    } else if (0.1 <= severity) && (severity <= 3.9) {
        color = "#0000ff"
    } else if (4.0 <= severity) && (severity <= 6.9) {
        color = "warning"
    } else {
        color = "danger"
    }
    return color
}

// FetchAccountAlias get account alias name
func FetchAccountAlias() string {
    var accountAlias string

    params := &iam.ListAccountAliasesInput{}
    res, err := svcIAM.ListAccountAliases(params)
    if err != nil {
        fmt.Println(err)
    }
    if res.AccountAliases == nil {
        accountAlias = "None"
    } else {
        accountAlias = *res.AccountAliases[0]
    }
    return accountAlias
}

// PostSlack post slack result
func PostSlack(slackColor string, title string, accountAliasName string, severity string, resource string, reason string, description string) {
    field0 := slack.Field{Title: "Title", Value: "_" + title + "_"}
    field1 := slack.Field{Title: "Account", Value: accountAliasName}
    field2 := slack.Field{Title: "Severity", Value: severity}
    field3 := slack.Field{Title: "Affected Resource", Value: resource}
    field4 := slack.Field{Title: "Type", Value: reason}
    field5 := slack.Field{Title: "Description", Value: "```" + description + "```"}

    attachment := slack.Attachment{}
    attachment.AddField(field0).AddField(field1).AddField(field2).AddField(field3).AddField(field4).AddField(field5)
    color := slackColor
    attachment.Color = &color
    payload := slack.Payload{
        Username:    USERNAME,
        Attachments: []slack.Attachment{attachment},
    }
    err := slack.Send(SLACKURL, "", payload)
    if err != nil {
        os.Exit(1)
    }
}

ServerlessFrameworkを利用したデプロイ

Lambda (Go) + IAMロール + CloudwatchEventをseverlessFrameworkを利用してデプロイしました。

■ デプロイコマンド

- Goコンパイル
  - $ make build

- ServerlessFrameworkでデプロイ
  - $ sls deploy --aws-profile <PROFILE> --slackurl <SLACKURL>
export GO111MODULE=on


## Install dependencies
.PHONY: deps
deps:
  go get -v -d


## Setup development
.PHONY: deps
devel-deps: deps
  GO111MODULE=off
  go get -u golang.org/x/lint/golint
  go get -u github.com/motemen/gobump/cmd/gobump
  go get -u github.com/Songmu/make2help/cmd/make2help


## Setup build
.PHONY: pre-build
build-deps:
  go get -u github.com/mitchellh/gox


## Build binaries
.PHONY: build
build: build-deps
  rm -rf ./main
  gox -os=linux -arch=amd64 -output=./main -ldflags "-s -w"
  gobump show


## Lint
.PHONY: lint
lint: devel-deps
  go vet ./...
  golint -set_exit_status ./...


## Show help
.PHONY: help
help:
   @make2help $(MAKEFILE_LIST)
  • $ make help
build:             Build binaries
build-deps:        Setup build
deps:              Install dependencies
devel-deps:        Setup development
help:              Show help
lint:              Lint
  • serverless.yml
service: guardduby
frameworkVersion: ">=1.48.0"

provider:
  name: aws
  stage: dev
  runtime: go1.x
  region: ap-northeast-1


functions:
  slack:
    handler: main
    role: GuardDutyLambda
    timeout: 30
    description: GuardDutyLambda slack notice
    memorySize: 128
    environment:
      SLACKURL: ${opt:slackurl}
      THRESHOLD: 2.0
    events:
      - cloudwatchEvent:
          event:
            source:
              - 'aws.guardduty'
            detail-type:
              - 'GuardDuty Finding'


resources:
  Resources:
    GuardDutyLambda:
      Type: AWS::IAM::Role
      Properties:
        RoleName: GuardDutyLambda
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        ManagedPolicyArns:
          - arn:aws:iam::aws:policy/IAMReadOnlyAccess
        Policies:
          - PolicyName: GuardDutyLambda
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - "logs:CreateLogGroup"
                    - "logs:CreateLogStream"
                    - "logs:PutLogEvents"
                  Resource: "*"

まとめ

AWS GuardDutyのアラート通知を CloudwatchEvent + Lambda (Go)を使ってSlackに通知する方法を記載しました。 デプロイについては、ServerlessFrameworkを利用しています。

GuardDutyの通知は、最近サービスリリースされた ChatBotが対応しているので利用してみてもいいかもしれません。 jp.techcrunch.com