👻

AWS ECRでイメージスキャンした結果をSlackに通知する

2024/02/09に公開

AWSのECRではDockerイメージを自動でスキャンすることができます。しかしスキャンした結果は勝手には通知してくれません。そこでEventBridgeを使ってSlackに通知するAWS CDKコード例を紹介します。CDKですが、内容は他のツールでも流用できると思います。

また、EventBridgeとSNSを組み合わせたり、Lambdaを組み合わせたりする方法が他の記事で紹介されていますが、実はEventBridge単体でSlackのIncomingWebhookにAPIリクエストして通知できます。

サンプルコード

いきなりサンプルコードを紹介します。EventBridgeで作成する通知は2種類です。

  • 脆弱性がHIGH / CRITICALが1件以上あるアラートモード
  • 脆弱性のHIGH / CRITICALがない通常モード
// NOTIFY_SLACK_INCOMING_URL 環境変数にSlack IncomingWebhookのURLをセットする
import {
  Stack,
  StackProps,
  SecretValue,
} from "aws-cdk-lib";
import { Construct } from "constructs";
import * as events from "aws-cdk-lib/aws-events";
import { PolicyDocument, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";

export class SampleStack extends Stack {
  private destination: events.ApiDestination;
  private execRole: Role;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    this.main();
  }

  main() {
    // 共通で利用するSlack送信先定義を行う
    this.createDestination();
    // 実行ロールを作成する
    this.createExecRole();

    // アラート用のルールを作成する
    this.buildAlertRule();
    // 緊急性の脆弱性が見つからなかったルールを作成する
    this.buildOkRule();
  }

  private buildAlertRule() {
    const eventPattern = {
      source: ["aws.ecr"],
      "detail-type": ["ECR Image Scan"],
      detail: {
        "scan-status": ["COMPLETE"],
        "finding-severity-counts": {
          $or: [
            {
              CRITICAL: [{ numeric: [">", 0] }],
            },
            {
              HIGH: [{ numeric: [">", 0] }],
            },
          ],
        },
      },
    };

    const text = `❌ ECRにpushされたイメージの脆弱性スキャンが終わりました
• スキャン日時: <time>
• リポジトリ名: <repository>
• Critical: *<Critical>件*
• High: *<High>件*

詳しくは詳細ページを確認してください
https://ap-northeast-1.console.aws.amazon.com/ecr/repositories/private/${process.env.CDK_DEFAULT_ACCOUNT}/<repository>/_/image/<imageSha>/scan-results`.replace(
      /\n/g,
      "\\n",
    );

    this.buildEventBridgeRule("NotifyImageScanResultAlert", eventPattern, text);
  }

  private buildOkRule() {
    const eventPattern = {
      source: ["aws.ecr"],
      "detail-type": ["ECR Image Scan"],
      detail: {
        "scan-status": ["COMPLETE"],
        "finding-severity-counts": {
          CRITICAL: [{ exists: false }],
          HIGH: [{ exists: false }],
        },
      },
    };

    const text = `✅ ECRにpushされたイメージの脆弱性スキャンが終わりました
• スキャン日時: <time>
• リポジトリ名: <repository>

脆弱性は確認されませんでした`.replace(/\n/g, "\\n");

    this.buildEventBridgeRule("NotifyImageScanResultOk", eventPattern, text);
  }

  private createDestination() {
    const connection = new events.Connection(this, "SlackConnection", {
      authorization: events.Authorization.apiKey(
        "dummy-key",
        SecretValue.unsafePlainText("dummy"),
      ),
    });

    this.destination = new events.ApiDestination(
      this,
      "SlackNotifyImageScanResult",
      {
        connection,
        endpoint: process.env.NOTIFY_SLACK_INCOMING_URL || "",
        httpMethod: events.HttpMethod.POST,
      },
    );
  }

  private createExecRole() {
    this.execRole = new Role(this, "EventBridgeExecRole", {
      assumedBy: new ServicePrincipal("events.amazonaws.com"),
      inlinePolicies: {
        inlinePolicies: PolicyDocument.fromJson({
          Version: "2012-10-17",
          Statement: [
            {
              Effect: "Allow",
              Action: "events:InvokeApiDestination",
              Resource: "*",
            },
          ],
        }),
      },
    });
  }

  private buildEventBridgeRule(
    name: string,
    eventPattern: events.EventPattern,
    message: string,
  ) {
    const inputTransformerProperty: events.CfnRule.InputTransformerProperty = {
      inputTemplate: `{"text":"${message}"}`,
      inputPathsMap: {
        time: "$.time",
        repository: "$.detail.repository-name",
        imageSha: "$.detail.image-digest",
        Critical: "$.detail.finding-severity-counts.CRITICAL",
        High: "$.detail.finding-severity-counts.HIGH",
      },
    };

    const target: events.CfnRule.TargetProperty = {
      arn: this.destination.apiDestinationArn,
      id: "SlackNotifyImageScanResult",
      inputTransformer: inputTransformerProperty,
      roleArn: this.execRole.roleArn,
    };

    // L1レベルでないと細かい設定ができない(トランスフォーマーなど)
    new events.CfnRule(this, name, {
      eventPattern,
      name,
      targets: [target],
    });
  }
}

結果で通知内容を変更する

EventBridgeの入力テンプレートには、初期値を設定することができない&条件分岐ができないため、脆弱性レポートの結果次第でSlackに通知する内容を切り替えることはできません。

素直にEventBridgeのルールを別で用意する必要があります。ルールのトリガーとなるイベントパターンでフィルタリングすることができるので、ここでアラートか通常かを分岐させます。

以下のイベントパターンは finding-severity-counts.CRITICALfinding-severity-counts.HIGH のキーが存在しないときに脆弱性がないと判断させています。

    const eventPattern = {
      source: ["aws.ecr"],
      "detail-type": ["ECR Image Scan"],
      detail: {
        "scan-status": ["COMPLETE"],
        "finding-severity-counts": {
          CRITICAL: [{ exists: false }],
          HIGH: [{ exists: false }],
        },
      },

逆に脆弱性がある判断は CRITICAL: [{ numeric: [">", 0] }] のようにします。ここの詳しい書き方はドキュメントを参照してください。

Slack IncomingWebhook URL登録

EventBridgeから直接WebAPIをリクエストすることができます。が、大前提として認証を求めるAPIじゃないと登録ができません。しかし普通のIncomingWebhook URLは単なるURLです。そこでダミーのリクエストヘッダを登録することで回避しています。

    const connection = new events.Connection(this, "SlackConnection", {
      authorization: events.Authorization.apiKey(
        "dummy-key",
        SecretValue.unsafePlainText("dummy"),
      ),
    });

動作確認用のDockerイメージ

脆弱性があるDockerイメージがないと動作確認ができません。僕はとりあえず古いNode.jsイメージを利用しました。

FROM node:16.0.0-buster-slim

逆に脆弱性がないサンプルイメージは軽量なAlpineを利用しました。

FROM alpine

これらのイメージをECRにプッシュして動作確認をすると良いでしょう。

ムーザルちゃんねる

Discussion