trivyをAWS Lambdaで動かす

4 min read読了の目安(約4200字

コンテナイメージの脆弱性スキャナであるtrivyは軽量かつスキャンのみに特化していることで、他のシステムとの統合がしやすいツールです。しかしCLIで動作させるような仕様となっているため、例えばAWS上で動作させるには専用のEC2インスタンスやFargateなどで常時起動するような環境を用意する必要があります。スキャンの流量が少ない環境だと常時起動させるとコストがかかってしまうため、今回はLambdaから起動させることでシンプルな構成かつ低コストなスキャンを実現します。

Lambdaの実行環境にtrivyを入れ込む

trivyのスキャンをLambda上で実行するには以下のアプローチが考えられます。

  1. trivyのソースコードをライブラリとして別のGoバイナリに取り込む
  2. trivyの実行バイナリをLambdaの実行用アセットに入れ込んで呼び出す
    1. CDKのbundlingオプションを利用する
    2. カスタムのLambdaレイヤーを使う
    3. コンテナイメージを使う

Lambdaは単体の実行バイナリ・コードを動作させるというのが基本なので、(1) は最も相性が良いと思われます。しかし現状trivyはスキャン機能を公式にライブラリとして提供していません。加えてリリース版ではなく今後も内部構造が大きく変わる可能性がある[1]ため、継続して使うことを考えると無理やりライブラリとして取り込むのは筋が悪そうです。

となると現状では実行バイナリをLambdaの環境に同梱させて実行するのが良さそうです。Lambdaの実行環境に単体の実行バイナリ・コード以外のアセットを入れ込む方法はいくつかあります。Lambdaレイヤーはデプロイパッケージを小さくしたり他のLambdaと共通のアセットを入れ込むのが主な用途かと思いますが、trivyの実行ファイルは30MB程度かつ今回は単体で動かすのでレイヤーを積極的に採用する理由は特にありません。コンテナイメージの利用も大容量のアセットを使う場合などには便利ですが、ECRを併用する必要があります。

ということで今回はより簡単な構成で使える、CDKのbundlingオプションを利用します。

bundlingオプションを使ったバイナリの入れ込み

CDKにおけるLambdaのbundlingオプションはAWSから提供されているコンテナイメージをもとに自分で一通り必要なコードなどを配置することができます。コンテナを起動してその中の /assert-output/ というディレクトリにコードなどを投げ込むとその中のものがすべてデプロイパッケージに詰め込まれてアップロードされます。

以下の例はGo言語になっていますが、trivyを起動させるのであれば言語は何でも構いません。この例では lambda/scanner 内のコードがtrivyのバイナリを呼び出す想定です。
Makefileでtrivyパッケージをダウンロード → 展開してバイナリを配置するようにしています。

const rootPath = path.resolve(__dirname, '..');
const asset = lambda.Code.fromAsset(rootPath, {
    bundling: {
        image: lambda.Runtime.GO_1_X.bundlingDockerImage,
        user: 'root',
        command: ['make', 'asset'],
        environment: {
            GOARCH: 'amd64',
            GOOS: 'linux',
        },
    },
});

new lambda.Function(this, 'scanner', {
    runtime: lambda.Runtime.GO_1_X,
    handler: 'scanner',
    code: asset,
    timeout: cdk.Duration.seconds(300),
    reservedConcurrentExecutions: 1,

    events: config.events,
});
Makefile
TRIVY_VERSION=0.16.0
TRIVY_URL=https://github.com/aquasecurity/trivy/releases/download/v$(TRIVY_VERSION)/trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz
TRIVY_BIN=./build/trivy

lambda: build/scanner

build/scanner: lambda/scanner/*.go
	go build -o build/scanner ./lambda/scanner

trivy: $(TRIVY_BIN)

$(TRIVY_BIN):
	$(eval TRIVY_TMPDIR := $(shell mktemp -d))
	mkdir -p build
	curl -o $(TRIVY_TMPDIR)/trivy.tar.gz -s -L $(TRIVY_URL)
	tar -C $(TRIVY_TMPDIR) -xzf $(TRIVY_TMPDIR)/trivy.tar.gz
	mv $(TRIVY_TMPDIR)/trivy $(TRIVY_BIN)
	rm -r $(TRIVY_TMPDIR)

asset: trivy lambda
	cp build/* /asset-output/

実行と結果の取り出し

その他、実際のLambda上で実行する際のtipsです

起動方法

用途によりけりですが、コンテナイメージをスキャンするというユースケースから考えると以下のような起動トリガーが考えられます

  • HTTP APIが叩かれた時にスキャンする:API gatewayとの連携が必要。API呼び出し時のパラメータにレジストリ、レポジトリ、タグなどを指定するというパターンが考えられ、それぞれ抽出してtrivyに渡す
  • 定期的にスキャン:CloudWatch Eventsの定期実行スケジュールを利用してLambdaを発火させる。決め打ちのレポジトリに対するスキャンではなくECR上のレポジトリを全部スキャンする、などであれば別途ECRのDescribeRepositoriesなどとの併用が必要
  • ECRにイメージがpushされたらスキャン:CloudWatch Event BridgeでECRのPushImageを拾ってLambdaを起動させることができる。イベント内にイメージのレポジトリやタグ情報なども含まれる

いずれの場合にしても trivy に外部からの入力値を渡して別プロセスとして起動する必要があり、実装の際はOS command injectionの脆弱性を作り込んでしまわないよう注意が必要です。特にHTTP APIでユーザからの入力を受け付ける等の場合は入力値チェックなどを適切にする必要があります。

実行方法

基本的に直接IPCするのは難しそう(標準出力に動作ログが入り込む場合がある[2]ため)なので、 --format json オプションを付け出力形式をJSONに指定し、 -o オプションで出力先のファイルを指定、正常に終了したら出力されたファイルの中身を読み取るというアプローチが現バージョン(v0.16) だと良さそうです。

スキャン結果の保存

Lambdaは基本的に実行環境が残らないため、(HTTP APIの結果に渡すなどの場合を除いて)trivyのスキャン結果をどこかへ退避させる必要があります。

AWS上であればデータストアはいくつか選択肢がありますが、スキャンするイメージによってはtrivyの出力結果はかなり大きいサイズになるという点については注意が必要です。パッケージの脆弱性が少ないイメージはたかだか数件ですが、なんらかの理由であまりアップデートできないイメージだと数百KB以上になる場合があります。このサイズを扱おうとすると、CloudWatch Logsで表示が壊れたり、DynamoDBでCapacity Unitの限界やドキュメントサイズの限界を超える、ということが起こりがちです。

個人的にはS3にスキャン結果をまるっとアップロードし、スキャン結果を利用する別のLambdaなどへS3のパスだけを渡すという方法を活用しています。

脚注
  1. https://twitter.com/knqyf263/status/1361181268348698624 ↩︎

  2. 一応 -q, --quiet オプションはあるのですが異常ケースでエラーログが入り込まないかどうかまではわからないので安全側に倒して考えています ↩︎