技術部セキュリティグループの水谷(@m_mizutani)です。最近はPCゲーム熱が再燃しており、今はCities: Skylinesに時間を溶かされ続けています。
クックパッドでは レシピサービスの継続的なサービス改善の他にも、生鮮食品販売プラットフォームの クックパッドマートやキッチンから探せる不動産情報サイト たのしいキッチン不動産をはじめとする新しいサービス開発にも取り組んでいます。さらに内部的なシステムも多数あり、動かしているアプリケーションの数は300以上に及びます。これらのアプリケーションには多くのOSSパッケージが利用されており開発を加速させますが、同時にOSSパッケージのアップデート、とりわけ脆弱性の修正にも向き合う必要があります。
これまでクックパッドでは(重大な脆弱性が見つかった場合を除いて)各サービスを担当するエンジニアが事業や開発の状況にあわせてパッケージのアップデートなどをしていました。しかし、管理すべきアプリケーションが多くなってきていることから、全社で統一したパッケージの脆弱性対応の仕組みを整える必要がでてきました。その一環として各アプリケーションのデプロイで使われるコンテナに含まれるパッケージの脆弱性を把握するための仕組みを整えました。
この記事では社内でのパッケージ脆弱性の検査に対してどのような要求があり、それをどうやって実現したのかを紹介します。
脆弱性スキャンのパイプライン構築における要件
現在、クックパッドでは大部分のアプリケーションがコンテナ化され、Amazon ECS(Elastic Container Service)上で動作しています。また、そこへのデプロイも主にCodeBuildを使ったCI(Continuous Integration)の環境が整備されています。そのため、このCIの仕組を利用することで脆弱性スキャンの機能を構築することにしました。
構築にあたってはいくつか解決しないといけない課題や要件があったため、それをまず紹介します。
要件1) 観測からはじめる
CI/CDにおける脆弱性管理の文脈では「CIのパイプラインで脆弱性を検査し、脆弱性があった場合はCIを止める」といったものが多く語られているように思います。検出されている脆弱性をすべて無くしてからしかデプロイできないようにする、というのは確かに理想形ではありますが、実際の事業に照らし合わせてみると必ずしも正しいとは言えないと考えています。
例えば1つのパッケージのバージョンを上げることで破壊的な変更が入る、あるいは連鎖的に複数のパッケージも更新する必要があり、結果的に大幅な改修が必要になってしまう、ということはままあることと考えられます。これが事業的に一刻も早くデプロイしなければならない状態だとすると、現場判断で脆弱性スキャンの機能を無効にせざるをえない、ということがありえます。
もちろん、攻撃が成功しやすい・影響が大きいような脆弱性の場合は事業を止めてでも修正する必要があります。しかし、脆弱性の中には複数の条件を突破しないと攻撃が成立しないような種類のものも少なからずあります。そしてそれはアプリケーションの設定や実行環境に依存するため、一律に判断するのは困難です。CVSSなどによるスコアリングでも、結局は環境などに依存してリスクが変動してしまい、これをセキュリティチームから開発チームに押し付けることは互いにとってあまり良い結果にならないのではと考えています。
そのため、まずはコンテナ内のパッケージの脆弱性がどのくらいあって、どのように変動しているかを把握し、どうすればリスクの極小化ができるかの仮設をたてて検証していく必要があります。そのためにも全体像を把握できるようにまずは観測できる環境を整えるという要求事項を設定しました。
要件2) CIと密結合にしない
いくつかの脆弱性スキャンツールはCIの途中で実行することを想定して作られており、CIのスクリプトなどに埋め込んでシンプルに実行することができます。しかし、アプリケーション数が多くなってくるとそれに比例して脆弱性スキャンツールを動かすための管理・統制にかかるコストが大きくなってしまいます。これは脆弱性スキャンツールの導入だけでなく、例えばツールの仕様が変わるなどしてうまく動かなくなった際の障害対応とメンテナンスの手間も含まれてきます。
先述したとおり、クックパッド内では300を超えるアプリケーションが動いており、それら全てのCIでそういった管理をするのはあまり現実的ではありませんでした。そのため、既存のCIの仕組みとは完全に独立させ、CI側に影響を与えないような疎結合なシステムを構築する必要がありました。これによって、今後さらにアプリケーションの数が増えても容易にスケールできることが期待されます。
要件3) 脆弱性の発見だけでなく修正もとらえる
脆弱性スキャンツールを使う主な目的は脆弱性のあるパッケージの発見であるため、検査結果をそのまま閲覧・通知することでこれは達成できます。しかし継続的にコンテナをメンテナンスしていく場合、コンテナに含まれる脆弱性が修正された、という情報も役に立つことがあります。
- 脆弱性のあるパッケージが含まれていたコンテナイメージ修正の進捗状況を把握できる
- 脆弱性のあるパッケージを更新したつもりのコンテナイメージをビルドした際、意図したとおりにパッケージが修正できたのか把握できる
- 脆弱性が発見されてから修正されるまでの期間を計測できる
これらを実現するためには各コンテナイメージの脆弱性の状態を管理する必要があります。
要件4) ベースイメージに含まれているパッケージの脆弱性を識別できるようにする
クックパッドではアプリケーション用のコンテナイメージを作成する際に利用できる、社内共通のベースイメージが用意されています。このイメージにはおおよそ共通して使われるであろうパッケージが事前にインストールされており、これを使うことでアプリケーション用イメージごとのビルドのステップを短縮しています。
しかし、ベースイメージからビルドされたコンテナイメージの脆弱性をスキャンすると、ベースイメージにもともと入っていたパッケージの脆弱性とアプリケーション用に新たにインストールしたパッケージの脆弱性が混在した結果が出力されてしまいます。発生ポイントがどこであれ修正するべき脆弱性は修正しなければなりません。ですが、ベースイメージを管理しているチームとアプリケーションを開発しているチームが異なるため、脆弱性の発生レイヤが混在して通知されてしまうと、どのチームが対応するべき脆弱性なのかが判断しにくくなってしまいます。このため、検出された脆弱性がどのイメージをビルドした際に入り込んでしまったのかを識別できるようにしたい、という要求が生まれました。
ベースイメージが1つだけであれば、そのイメージの検査結果との差分をみることで脆弱性の発生ポイントを判定できますが、ベースイメージが複数あるとその紐付けの情報を管理する必要がでてきます。Dcokerfileからビルドする場合は FROM
を見ることでベースイメージのレポジトリはわかりますが、いつビルドされたイメージが実際に使われているのかまではわかりません。とはいえ手動で管理するのはあまりにも煩雑なので、自動的に判定するような仕組みが必要になります。
脆弱性スキャンツールの選定
脆弱性スキャンのツールとしてはTrivyを採用しました。選定にあたって他のOSSや製品の脆弱性スキャンツールとも比較をしたのですが、
- 単体のバイナリだけで簡単にスキャンが実行でき、小回りがきくこと
- 入力や出力もシンプルになっており自分たちのシステムとのインテグレーションが容易であること
- OSのパッケージおよびrubyなどランタイムのパッケージの脆弱性もまとめて把握できること
という3つの理由からTrivyを使うことにしました。
ちなみに、クックパッドではCI/CDにおけるコンテナイメージの保存にはAmazon ECR(Elastic Container Registry)を利用しており、ECRのImage Scanningの機能を利用することも検討しました。しかし、スキャンできる対象がOSのパッケージのみだったことから採用を見送りました。
ちょうど先日、AWS Security Blog で How to build a CI/CD pipeline for container vulnerability scanning with Trivy and AWS Security HubというTrivyをCIに取り入れるというブログが公開されていました。このブログでもCodeBuildでのCIを想定しており、CIの中にTrivyによる脆弱性スキャンを実行して、その結果をSecurity Hubに格納するというアーキテクチャについて述べられています。このアプローチも小さくはじめるにはよい構成なのですが、先述した要件をクリアするのは十分ではなかったため、我々は別のアーキテクチャによって脆弱性スキャンのパイプラインを実現しました。
アーキテクチャと実装
TrivyとAWSの各種マネージメントサービスを利用し、コンテナイメージの脆弱性スキャンパイプラインを構築しました。AWSのサービスと接続することから、基本的な制御の部分にはLambdaを利用し、サーバレスなアーキテクチャになっています。デプロイにはAWS CDK(Cloud Development Kit)を利用しています。
また、アーキテクチャ図からは省いていますが、スキャン結果から得られたデータを確認するためのWeb管理コンソールも用意しています。
イメージのスキャン
クックパッドでは原則コンテナイメージをCodeBuildでビルドし、ECR(Elastic Container Registry)にプッシュしたのち、ECS(Elastic Container Service)へデプロイするという構成になっています。要件2の疎結合なアーキテクチャにするという観点から、今回はCodeBuild内で実行されるビルドのプロセスには一切手を加えず、ECRにプッシュされたイメージを利用することで、CI/CDのパイプラインに一切影響しないような構成にしました。
スキャンの開始は2つのトリガーがあります。1つはイメージがプッシュされた際にCloudWatch Events経由で送信されるECRイベント、もう1つは定期的(現在は24時間ごと)に発行されるCloudWatch EventsのScheduledイベントです。それぞれのトリガーによって起動されたLambdaがスキャンすべき対象のイメージの情報をキューとしてScanQueueに詰めます。定期的に実行されるトリガーはECRからレポジトリの一覧を取得し、そこからスキャンが必要なイメージを選定します。
ECRにプッシュされたイメージの中身は後からは変更されないため、同じ脆弱性を見つけるためには何度もスキャンする必要はありません。しかし脆弱性スキャンツールにTrivyを使う場合、新たに発見された脆弱性を見つけるためには脆弱性DBを更新して、再度検査をするというのがシンプルな対応になります。そのため、イメージがプッシュされたイベントとは別に定期実行の仕組みを取り入れました。
Trivyを使った実際のスキャンはFargate上で実行することにしました。Fargateを選択した主な理由は、1) 実行環境が独立しているため、ECSのように他のタスクに影響を及ぼさない、2) スケールアウトが容易、の2つになります。特に定期スキャンでは数百のイメージをスキャンするためのキューが一度に発生するため、スケールアウトによって短時間でスキャンを完了させられます。Fargate上ではこのパイプラインを制御するためのプログラムを動かしており、それがTrivyを起動させます。具体的には、次のような制御をしています。
- ScanQueueからスキャン対象イメージの情報を取得
- 脆弱性DBの更新(図中では割愛)
- Trivyの起動とスキャン結果の保存
- 対象イメージのレイヤ情報をECRから取得
- スキャン結果をS3に保存
- スキャン完了通知をResultQueueに送る
Trivyのスキャン結果は多少のメタデータを付与したあと、なるべくそのままS3に保存します。これのデータをもとに結果処理のLambdaが管理コンソールからの検索に必要なインデックス情報などをDynamoDBに保存します。
脆弱性の状態管理
脆弱性の状態を管理するのに必要なのは「直前のスキャン結果との比較」です。これはRDBを使って管理するというようなアプローチもありますが、今回はS3に保存してあるスキャン結果を単純に比較してコンテナイメージに含まれる差分を計算する、という方法にしました。これによってイメージごとの差分計算処理が1つのLambdaに集約され、大量のリクエストがきても容易にスケールアウトできます。
差分計算の処理はシンプルに最新のスキャン結果と直前のスキャン結果を比較しているだけです。最新のスキャン結果が保存されたS3パスが(「イメージのスキャン」のアーキテクチャ図にもあった)スキャン結果処理のLambdaから送信されたQueueに、直前のスキャン結果が保存されたS3パスがDynamoDBにあります。これらをもとに、それぞれのスキャン結果をS3からダウンロードし、新しく出現した脆弱性と削除された脆弱性の情報を比較結果としています。比較結果のデータサイズがSQSのデータサイズ制限(256KB)を超える可能性があるので、比較結果を直接SQSには流さずS3へ保存しています。その後、SNS → SQS を経由して Lambda に通知を送り、DynamoDB上にある脆弱性の状態(未修正・修正済み)を更新したり、Slackに通知したりしています。
管理コンソールからはどのコンテナイメージのどこにその脆弱性があり、それぞれの修正状況も把握できるようなユーザインターフェイスを用意しました。これによって社内での脆弱性対応の進捗が可視化されています。
ベースイメージの判定
「要件4) ベースイメージに含まれているパッケージの脆弱性を識別できるようにする」で説明したとおり、ベースイメージに含まれているパッケージの脆弱性とアプリケーション開発によって追加されたパッケージの脆弱性とを区別する仕組みを取り入れました。この判定には各イメージのLayer Digestを利用しています。ベースイメージを利用してイメージをビルドする場合、ビルドしたイメージは一部のレイヤーをベースイメージと共有しています。そのため、Layer Digestが一致すればそれ以前のレイヤーは基本的にすべてベースイメージのものである、と判断することが出来ます。
Trivyのスキャン結果には各脆弱性が含まれるレイヤーのLayer Digestが記載されているため、アプリケーションイメージのどのレイヤーがベースイメージ由来なのかがわかっていれば、脆弱性を含むパッケージがどちらに属しているのかも判断できます。どのレイヤーからベースイメージなのかを後から判定するため、スキャン結果とLayer Digestの一覧を組み合わせて保存しておく必要がありますが、残念ながらTrivyのスキャン結果に記載されていません。しかしLayer Digestの一覧はECRに保存されているため、代わりにECRへアクセスすることで取得できます。先述したとおり、fargate上でのスキャン時にはTrivyのスキャン結果とECR上のレイヤ情報の両方を取得し、組み合わせてS3へ保存しています。
このような仕組みでベースイメージを検出するために、検索用のデータストアとしてDynamoDBを使っています。DynamoDBに全てのイメージの最新レイヤーのLayer Digestをキーとして保存し、アプリケーションイメージの脆弱性一覧を表示するタイミングで全てのLayer Digestをバッチで問い合わせ、その結果からどこからベースイメージかを判定します。一覧表示のタイミングで検索しているのは、ベースイメージとアプリケーションイメージがほぼ同時に更新された際、スキャン結果の到着が前後する可能性があるためです。
この仕組を使うことで、どのレポジトリやタグがベースイメージとして使われているのかという情報をメンテナンスしなくても、自動的に判定ができるようになりました。また、ベースイメージが複数ある(ベースイメージAからベースイメージBが作られ、ベースイメージBからアプリケーションイメージが作られる)場合でも、同じ仕組みによって正確に複数のベースイメージを判定できます。管理コンソールでは次の図のようにベースイメージ由来の脆弱性はリンク先で確認するようなUIにしました。
コスト
今回のアーキテクチャではコスト削減を目的としていたわけではないのですが、結果としては一日あたりの動作コストが$6弱になりました。
その中でも支配的なのがDynamoDBで、1日あたり$4ほどのコストになっています。これはCapacity設定の最適値が読めないため on-demand capacity mode で動作させているためと考えられ、これは今後適切な値でRead/Write Capacityを設定しAuto scalingと併せて使うことで改善できると考えています。また、クエリについても改善の余地がありそうな部分はあり、そちらも今後リファクタしていきたいと考えています。
一方、CPUリソースが必要とされるTrivyのスキャンに関しては一日あたりおよそ$0.5ほどになっています。これはスケールイン・アウトがうまく機能していること、そしてFargate spotを使っていることで大きくコストを抑えていると見ています。Fargate spotなので処理の途中で停止してしまう可能性もありますが、どの段階で処理が止まってもやり直しがきき、かつ複数回処理が実行されても冪等になるように実装しているため、特に問題なく利用できています。
まとめ
この記事ではTrivyとAWSのマネージドサービスを使った、CI/CDと疎結合にコンテナイメージの脆弱性スキャンパイプラインの要件、アーキテクチャと実装の一部を紹介しました。これは永続的に疎結合のまま運用することを目指しているわけではなく、CI/CDの中に直接組み込むとしたらどのような仕組みや運用ポリシーが必要になるか?という課題を解くための前段階という意味合いもあります。技術部セキュリティグループでは引き続きどのようなパッケージの脆弱性管理の戦略をとれば事業開発のスピードへの影響を最小化しつつセキュリティを担保していけるか、という問題にチャレンジしていこうと考えています。
このようなエンジニアリングのチャレンジをするにあたり、クックパッドでは(引き続き)セキュリティエンジニアを募集しています。情報セキュリティに強い方だけでなく、むしろサービス開発を得意としつつセキュリティにも強い関心がある、という方にも興味を持っていただければ幸いです。