サーバーレスで作るセキュリティアラート自動対応フレームワーク

技術部セキュリティグループの水谷 ( @m_mizutani ) です。ここしばらくはフルリモートワーク体制になったので運動不足解消のためウォーキングをしたり筋トレしていたら、リモートワーク前より健康になった疑惑があります。

クックパッドのセキュリティチームでは日々のセキュリティ監視を効率化するため、独自のフレームワークを構築して利用しています。具体的には、セキュリティアラートが発生した際に自動的に様々なデータソースから関連情報を収集し、収集した情報をもとにアラートのリスクを評価、そして評価結果をもとに自動対応をするという一連のワークフローを実現するフレームワーク DeepAlert をAWS上にサーバーレスで構築しました。この記事では、このフレームワークを構築した経緯やアーキテクチャ、仕組みについて解説します。

セキュリティアラートの対応

ここでは、セキュリティ侵害が発生している可能性があるものについて管理者に対応を求めるようなメッセージをセキュリティアラートと呼んでいます。これはセキュリティ防御・監視装置(Firewall、IDS/IPS、WAF、AV、EDR、などなど)から直接アラートとして発せられることもありますし、ログの中から見つかった不審な活動や外部からの連絡によって発覚する事象など、発生の経緯は様々です。共通しているのは組織内でセキュリティ上の問題が発生している可能性があり、状況に応じてなんらかの対応が必要である、ということです。今回は特にセキュリティ防御・監視装置から発報されるものを中心に説明します。

セキュリティアラートは組織内のセキュリティの侵害の可能性を見つけてくれる便利な情報ではありますが、実際に脅威ではないものを発報してしまうケースが多々あります。これは主に次の2つの理由が挙げられます。

  • 防御・監視システムは見える範囲に限りがあり、別システム(特に社内システムなど)の情報とつきあわせないと判断できないこともセキュリティアラートとして発報してしまう
  • 防御・監視システムは一般化されているため、それぞれの会社や事業部ごとに特有の業務や文脈に対応しきれない場合がある

これによっていわゆる誤検知・過検知が発生してしまうため、担当するセキュリティエンジニアが都度調査・分析してアラートの実際の深刻度を判断する必要があります。この作業は組織のセキュリティを維持するための大切な業務ですが、日々持続的に発生するために他の業務を徐々に圧迫してしまい、件数が多くなることで担当者を疲弊させてしまいます。

通常、防御・監視システムにはホワイトリスト機能が備わっており、指定した条件に一致するアラートは発報しないよう設定ができます。しかし、実際には先述した通り他のシステムの情報と突き合わせないと判断が難しいケースやホワイトリスト機能では除外条件を表現しきれないケースがあり、容易に対応できるものでもありません。

まとめると、実際のセキュリティアラートの対応では、まずアラートの調査と分析(影響有無の判断)があり、その後で必要に応じて何らかのアクションをする、といった流れになります。この一連の作業を効率化するために、自律的に複数の情報を組み合わせて必要な対応をしてくれる仕組みを作ることにしました。

設計方針

セキュリティの対応をコード化する

この仕組を作るにあたっては対応の部分をなるべくコード化する、ということを指針として取り組みました。近年、サービス開発の分野ではインフラの管理にDevOps や Infrastructure as Code の概念によってリソースの管理や手続きをコード化する、という取り組みが盛んになっていると思います。これはセキュリティにも応用できる考え方であり、コード化することによって対応の自動化だけでなく、対応内容の明確化や変更履歴の管理、手続きに対してテストができるようになる、といった恩恵を受けることができます。

セキュリティアラートの対応はその組織の構成、活動内容、文化や発生時の文脈に依存するも多くあり、全てのアラートを一律に自動化して対応できるものではありません。しかし、多くのアラートについては決められた手順で関連する情報を調査し、得られた情報をもとに定められた基準に従ってリスクを評価することができると考えられます。自分が前職でSOC(Security Operation Center)に勤めていたときも、明文化こそされていなかったものの定型化された対応は多く存在し、かつアナリストの間で共有されていました*1。もちろん、定型的に判断しきれないアラートや新しい脅威に対してはセキュリティエンジニアによるきめ細やかな分析が必要になりますが、そうでないものはなるべく自動化することで、エンジニアがより本質的な作業に注力できるようになります。

そのため、このフレームワークを作るにあたっては「誰がやっても同じ結果になるものは人手を介さないようにする」という機能の実現を目指し、全体をコード化するという方針で設計しました。

その他の機能要件

セキュリティのコード化以外にも、次のような要件を考慮して設計しました。

  • 容易な機能拡張:関連する情報の検索や最終的な対応は、様々なデータソースやインターフェイスに対応する必要があります。また、状況に応じて機能を追加・削除していくと考えられるため、なるべく自動対応のメインのシステムとは疎結合になるようにするべきと考えました。
  • 低コスト運用:「横断的に複数のデータソースを使ってアラートの精度を上げる」といったアプローチは新しいものではなく、昔からSIEM(Security Information & Event Manager)でも同じような取り組みがされていました。しかし、多くのSIEMはリアルタイムにイベントを処理するような設計となっているため高い処理能力が求められ、高価になってしまう傾向があります。もちろんお金で解決すべきところにはお金を投入するべきですが、セキュリティは直接的にビジネスに貢献するものではないこともあり、工夫次第でコストを抑えられるならそうするべきと考えました。
  • 弾力性:現状、クックパッドでは平均して一日あたり数件のアラートしか発生していませんが、今後の新しい脅威や方針の変化にともなってアラートの流量が増える可能性があります。そうした場合にスケールアップでしか処理量の性能をあげられないとするとすぐに限界がきてしまい、対応が滞ってしまう可能性があります。もともとの設計で速やかにスケールアウト・スケールインができるような弾力性を備えておくことで、突発的な流量の変化にも耐えられるようになります。

セキュリティアラート自動対応フレームワークの実装

アーキテクチャ概要

f:id:mztnex:20200317083451p:plain

設計で説明したような機能を実現するため、 DeepAlertというAWS上にサーバーレスで構築されたフレームワークを実現しました。これはセキュリティアラートを外部から受け取り、Inspector、Reviewer、Emitterという3つの役割を持つAWS Lambda Functionと連携して動作します。それぞれのLambda Functionの役割は次のとおりです。

  • Inspector:アラートに出現したIPアドレス、ドメイン名、ユーザ名に関して内外のデータソースにアクセスし、必要な情報を収集します。例えば外部から接続してきたIPアドレスであればブラックリストに掲載されているか、ドメイン名であればどういったサービスに使われているか、内部システムのユーザ名であればアラート直前までの行動ログなどを収集し、それらの結果をDeepAlertに返します。
  • Reviewer:アラートおよびInspectorが調査した結果を元にそのアラートのリスクを評価します。評価結果はシンプルに safe(影響なし)、unclassified(不明)、urgent(影響あり、要対応)の3種類のみにしています。
  • Emitter:Inspectorの調査結果、そしてReviewerの評価結果を元に対応を請け負います。対応も色々種類があり、調査や評価の結果をSlackなどを通じて通知する、外部の特定のIPアドレスからの接続を遮断する、あるいは対象ホストを隔離する、というような処理を想定しています。この対応も、影響ありの状況だったら即座に遮断したり、影響がなければ記録だけして通知はしない、というような評価結果に基づいた動作の振り分けも考慮しています。

これらのLambda FunctionはDeepAlertとは独立しており、特にInspectorとEmitterは任意の種類、数を接続することが可能になっています。それぞれAWSのSNS(Simple Notification Service)、SQS(Simple Queue Service)、Step Functionsを使うことでDeepAlertと連携しています。より具体的なアーキテクチャが次の図になります。大まかな動作として3段階に分かれており、Inspectorを動かす 1) 調査フェイズ、Reviewerを動かす 2) 評価フェイズ、そしてEmitterを動かす 3) 対応フェイズとなっています。

f:id:mztnex:20200317083605p:plain

この通り、DeepAlertはInspector、Reviewer、Emitterを動かすためのフレームワークとして実装しました。これまで様々な改良を続けてきたのでやや異なる部分はありますが、この仕組で約2年ほど運用し、その間にInspector、Reviewer、Emitterを必要に応じて入れ替えてきました。

Lambda、Step Functions、SNS、SQS、DynamoDBのみでサーバーレス構成として実装したため、料金は完全に利用量に基づいて計算されるようになりました。具体的には後述しますが、流量が少なければ非常に安価に使うことができます。また、各サービスにリソースの上限は設けられているものの、その限界までは人間の手を介することなく自動的にスケールアウト・スケールインしてくれます。これによって運用における金銭的コスト・人的コストの両方を極力抑えられています。

Pluggableな機能拡張

要件のパートで説明したとおり、情報収集をするInspectorと最終的に対応をするEmitterは状況の変化に応じて機能を追加・変更・削除していく頻度が多くなっています。我々はこの仕組を2年ほど運用していますが、その過程でも監視すべき対象が変わったりチームの運用方法にあわせて調査対象や対応方法が変化しています。

そこで、Inspector、EmitterはDeepAlert本体とは疎結合な形でデプロイできるようにしました。DeepAlertのリソースはAWS SAM(Serverless Application Model)およびCloudFormationでまとめてデプロイしていますが、InspectorやEmitterはそれぞれ任意の複数種類のLambdaをDeepAlertとは別のSAMでデプロイしてもいいですし、Cloud9で実装したものをデプロイするでも問題ありません。InspectorとEmitterはそれぞれSNS経由で必要な情報を受け取って起動し、InspectorはSQS(ContentQueue)で調査結果をDeepAlert側に戻します。また、Inspectorが関連する情報を調査する過程で、新たに調査すべき要素(例えば調査対象のユーザが使っていた別のIPアドレスや、マルウェアのハッシュ値からそのマルウェアが使っていたCommand & ControlサーバのIPアドレスなど)が発見された場合も、その情報をSQS(AttributeQueue)を通じてDeepAlertに戻して再度その要素についてInspectorが調査する、というフィードバックの仕組みも実装されています。このようにSNSとSQSだけを用いてintegrationする仕組みにすることで、Inspector、Emitterの動作がDeepAlert全体の動作に影響を与えないようにしています。

ちなみに、これまでクックパッド内では次のようなInspector、Emitterを運用してきました。カッコ内はアクセスするデータストアやサービスになります。(すでに利用しなくなったものも含みます)

  • Inspector
    • IPアドレス、ドメイン名、ファイルのハッシュ値がマルウェアに関連しているかを調査する(VirusTotal、Malwarebytes)
    • 出現したURLのスキャンし、どのようなサイトだったのかの情報を調査する(urlscan.io)
    • 社員の誰がそのIPアドレスを利用していたのかというログの抽出(社内のIPアドレス管理DB)
    • そのホストに自社管理のセキュリティソフトがインストールされているかの確認(CrowdStrike Falcon)
    • IPアドレス、ドメイン名、ユーザ名に関連する直近のログの抽出(社内のセキュリティログ検索基盤)
  • Emitter
    • 評価結果に基づいてアラートの通知(Slack)
    • アラート対応の割り振り(PagerDuty)
    • 調査結果アラート情報をまとめて保存(GitHub Enterprise)

Emitterについては、本来は被害をうけたと見込まれるホストをネットワークから隔離したり、証拠保全のプログラムを実行したり、ということも想定はしていました。しかし、幸いにも私自身が入社して以来、そういったことを即座に実行する必要があるようなインシデントに遭遇したことがなく、サービスに影響するような能動的な対応をどのくらいの確信度で実行するべきかというルール化ができていないため、そういった機能はまだ実装していません。これについては今後の課題としたいと考えています。

一般的なプログラミング言語でコード化したポリシー

先述したとおり、Reviewerは調査で集められたアラートの情報をもとに、そのアラートが実際の被害を及ぼしたのかを評価します。評価の方法や仕組みは Lambda Function にコードとして自由に記述できるようにしました。現在、クックパッド内ではGo言語を使ってポリシーを記述していますが、DeepAlert側で規定した Lambda Function に対する入力と出力のインターフェースに則っていればどのような言語で記述できます。

SIEMをはじめとする多くの製品では独自の記法でポリシーを記述ようになっています。これはポリシーに記述する要素を厳選し、入力する内容を減らすことで容易に表現できることを目的としていると考えられます。このような仕組みになっていることで、単純なポリシーであれば低い学習コストで記述できるようになります。しかし複雑な条件を扱う必要が出てくると、ポリシーを分割して見通しを良くするということができなかったり、任意のテストができないために検証のコストが大きくなってしまう、という課題に直面しがちです。また、デバッグの手段が用意されていない場合も多く、ひたすらトライ&エラーを繰り返して検証する必要がある、という問題にも悩まされます。ポリシーの記述力もあまり柔軟ではない場合が多く、愚直な処理を繰り返し書かなければいけなかったり、ちょっとしたデータ形式の変換などができず消耗するといったこともしばしばありました。

DeepAlertを実装する際、一般的なプログラミング言語でポリシーをコード化することでこれらの問題の多くを解決できると考えて、入出力のインターフェースだけを定義しました。DSLやライブラリを使って評価するような機能を提供しないことによって、言語の種類に対する依存も極力ないようにしました。これによって、通常のプログラミングにおけるコード整理やテストの技法を取り込むことが可能となり、ポリシーが複雑化しても見通しがよくテスト可能な形で記述することができるようになります*2

Reviewerの入出力定義

Reviewerに対する入力のサンプルを以下に示します。

{
    "id": "61a97323-b7dc-4b13-a30d-7b423388da5f",
    "alerts": [
        {
            "detector": "AWS GuardDuty",
            "rule_name": "High Severity Finding",
            "rule_id": "guardduty/high_sev",
            "alert_key": "xxxxxxxxxxxx",
            "description": "Unusual resource permission reconnaissance activity by PowerUser.",
            "timestamp": "2020-03-12T18:07:10Z",
            "attributes": [
                {
                    "type": "ipaddr",
                    "key": "remote IP address (client)",
                    "value": "198.51.100.1",
                    "context": [
                        "remote",
                        "client"
                    ]
                },
                {
                    "type": "username",
                    "key": "AWS username",
                    "value": "mizutani@cookpad.com",
                    "context": [
                        "subject"
                    ]
                }
            ]
        }
    ],
    "sections": [
        {
            "author": "addrmap",
            "type": "host",
            "content": {
                "activities": [
                    {
                        "last_seen": "2020-03-12T00:46:17.000928Z",
                        "principal": "mizutani",
                        "remote_addr": "198.51.100.1",
                        "service_name": "AzureAD",
                        "owner": "Cookpad"
                    },
                    {
                        "last_seen": "2020-03-12T12:39:11Z",
                        "principal": "mizutani",
                        "remote_addr": "198.51.100.1",
                        "service_name": "Falcon",
                        "owner": "Cookpad"
                    }
                ]
            }
        }
    ]
}

元にしているのはAmazon GuardDutyから発報されたアラートです。説明のためにいろいろと省略していますが、基本となる要素は含まれています。(詳細な定義については こちら から参照することができます)

まず alerts がセキュリティ監視・防御システムから発報されたアラートになります。このアラートについても独自のフォーマットになっているため、発報するシステムとDeepAlertの間で1つLambdaを挟んでフォーマットの変換をしています。 attributes の部分にはそのアラートに出現した属性値になります。それぞれIPアドレスやユーザ名といった型を付けているのは従来のSIEMなどと同じですが、 context というフィールドをもたせることでその属性値の意味がわかるようにしています。これは、例えば「Source IP address」という型でアラートの属性値が正規化されていたとしても、それが内部と外部のどちらのネットワークを意味するのかであったり、何か攻撃をした側なのか、それとも攻撃を受けた側なのかということが発報時の文脈によって変わってしまうという問題に対応するための説明用フィールドとなっています。

そして、Inspector によって収集された情報を格納したのが sections になります。ここでは社用PCがどのIPアドレスからどのサービスを使っているかというaddrmapという内製ツールからの情報が付与されています。これを見ることで AzureAD および CrowdStrike Falcon でもアラートがあがったIPアドレスから同様に接続があったことが示されています。こういった情報をReviewerが参照し、ポリシーで影響あり・なしの判断ができるのであればそれを出力として伝える、判断できないのであればセキュリティエンジニアの判断に委ねる、といった処理をしています。

この入力のスキーマについてはSTIXのような既存の脅威情報を記述する構造を利用することも考えましたが、本来の目的が違うこと、我々がやりたいことから見て機能が過剰であること、互換性を維持する意味があまりないことから独自の形式にしました*3

一方、出力についてはシンプルで、severityreason の2つを入れるのみです。先述したとおり、severitysafeunclassifiedurgent の3段階でのみ表現されます。

{
    "severity": "safe",
    "reason": "The device accessing to G Suite is owned by Cookpad"
}

記述されたポリシーの例

具体的なポリシーの記述例を以下に示します。

// AWSへの不審なログインのアラートを評価するポリシーの例
func handleAlert(ctx context.Context, report deepalert.Report) (deepalert.ReportResult, error) {
    for _, alert := range report.Alerts {
        // アラートが対象のものでなかったら評価しない
        if report.RuleID != "guardduty/high_sev" {
            return nil, nil
        }

        // オフィスのIPアドレスからのアクセスの場合はこのポリシーでは評価しない
        if hasOfficeIPAddress(alert.Attributes) {
            return nil, nil
        }
    }

    // Inspectorによる調査結果を抽出
    reportMap, err := report.ExtractContents()
    if err != nil {
        return nil, err
    }

    // アクセス元のホストに関して Inspector が取得した情報をチェック
    for _, hostReports := range reportMap.Hosts {
        for _, host := range hostReports {
            for _, owner := range host.Owner {
                // そのホストの所有者が Cookpad のものであると確認できるログがあった場合、
                if owner == "Cookpad" {
                    return &deepalert.ReportResult{
                        // Safe(影響なし)と判断する
                        Severity: deepalert.SevSafe,
                        Reason:   "The device accessing to G Suite is owned by Cookpad.",
                    }, nil
                }
            }
        }
    }

    return nil, nil
}

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

このコードは説明のために簡略化していますが、おおまかな流れは実際のものと変わりません。先程の入力データの例では、不審なログインのアラートに対して、Inspectorが別の社内向けサービスを利用していたという情報を付与していました。このポリシーではその付与された情報を確認して、それが社員のPCが使っているIPアドレスからのアクセスなのか、それとも全く関係ない海外のサーバなどからのものなのかを確認し、もし社員のPCであると考えられる場合は safe(影響なし)という判断を返します。もし判断に足る情報がなければ、何も返さないことで unclassified(不明)と判定されます。

このポリシーとして記述された関数 handleAlert を用いることによって、アラート評価のテストを記述することができるようになります。この例では1つアラートに対するポリシーだけを記述していますが、実際には複数種類のアラートに対応できるようなコードを書く必要があります。新しくポリシーを追加したり、既存のものを変更した時、意図していない変更がまぎれていないかを確認するために常にテストで確認ができることで、自信を持ってポリシーをデプロイすることができるようになります。

その他のアーキテクチャの工夫

  • DynamoDBのベストプラクティスにもあるように、データストアは一つのDynamoDBのテーブルに押し込めています。このテーブルは複数アラートの集約、アラート情報の一時的な保持、新しく出現した属性値の管理、Inspectorの調査結果の保持などに利用しています。
  • 調査フェイズと評価フェイズにおいてLambdaの実行制御にStep Functionsを挟んでいるのは、アラートの到着から調査を開始するまでにわざと遅延を入れるためです
    • Inspectorの調査活動でも特にログを検査するタイプのものは、アラートが到着した直後ではまだログを参照できる状態になっていないことがあるため、数分程度待ってからInspectorを起動します
    • また、Inspectorが疎結合で任意の数実行されることから、DeepAlert側では同期的にInspectorの制御はしていません。そのため非同期に実行されたInspectorの結果を待つためにもStep Functionsを使っています

DeepAlert導入の効果

深刻度の自動評価による運用負荷の軽減

この仕組みを運用し始めておよそ2年ほどになりますが、2019年の実績では約50%ほどのアラートを人間が確認する前に影響なしであることを確認できました。これによって、対応に割く時間を大幅に減らす事ができました。

実際には影響がなかった場合でも全体の傾向の変化があった場合には気づきたいので、Slackで「影響なしのアラートが発生した」ということだけは通知させています。

f:id:mztnex:20200317083704p:plain

また、「影響なし」以外の判断がされたものについても、GitHub Enterpriseでアラートの詳細を記載したIssueを作成し、その上で対応の管理をしています。Issueについても最初にセキュリティアラートとしてDeepAlertが受信した情報だけでなく、Inspectorが取得した情報もあわせて記載しています。例として以下に示しているIssueでは、セキュリティログ検索システムへのリンクやVirusTotalへInspectorが問い合わせて取得した情報もあわせて掲載しています。これらの情報だけでは機械的に判定ができなかったわけではありますが、セキュリティエンジニアが自分で調査する際にも同じような情報をもとに作業することになるので、これらの情報が予め掲載されているということは作業時間の短縮に繋がります。このような点から自動評価できなかったアラートに対しても運用の負荷が下がっていると言えます。

f:id:mztnex:20200317083720p:plain

〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 中略 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

f:id:mztnex:20200317083735p:plain

コスト

結論から言うと、直近半年のデータから計算された実費用は一ヶ月あたりで約 $1.3 でした。アーキテクチャの説明でも述べた通り、DeepAlertはクラウドの金銭的運用コストおよび弾力性を考えてLambda、Step Functions、DynamoDB、SQS、SNSといったリソースのみで構成されています。原則としてどれも使ったリソース量に応じてのみ課金される構成が可能であり、対処するアラートの流量が少なかったり、アラートの発生頻度にむらがあるような場合でもコストを小さく抑えやすくなっています。

まとめ

DeepAlertは、既存のSIEMのように「複数のイベントを組み合わせて(精度の高い)セキュリティアラートを発報する」という考え方ではなく、「(精度の低い)セキュリティアラートに対して情報を付与し、精度を高める」といった戦略になっています。そのため、純粋な機能面でのできること自体はSIEMのサブセットという位置づけになってしまいますが、運用のしやすさやコストメリットなどから、このアプローチが最も効果的であると判断して取り組んできました。このように既存の製品だけにとらわれず、それが本当に必要であれば自分たちで作る、といったところまでやりきれるのが、事業会社でセキュリティをやる楽しさの一つなのではないかなと思います。

クックパッドではこうしたセキュリティにまつわる課題を一緒に解決していくエンジニアを絶賛募集しています。興味のある方はぜひこちらをご参照いただくか、ご質問などあれば水谷( @m_mizutani )などまでお声がけください。

*1:これは自分たちの業務を楽にするというだけでなく、SOC全体のクオリティを一定に保つ、という効果もあったと考えています

*2:かならずしも見通しがよいコードが書かれることが保証されるものではありませんが、できる余地があることが重要だと考えています

*3:ただしSTIXなどからDeepAlertの形式に変換するというのは意味があるかもしれないので、必要があれば実装したいと考えています