【Terraform】 ECR 拡張スキャン通知用の Lambda をデプロイしてみる

こんにちは。
先日に投稿した 「Inspector V2 の ECR 拡張スキャン結果を Slack に通知する」 ですが、Lambda のみ手動で作成していたので、こちらも Terraform でデプロイできるように実装します。

【Terraform】Inspector V2 の ECR 拡張スキャン結果を Slack に通知する

Terraform

Terraform で Lambda をデプロイするには、予めソースコードを zip 化しておく必要があるようです。
ついては archive_file という Data Source で対応します。
ソースコードの更新も、zip ファイルの hash 値で判断してくれるようで便利ですね。

/** 
# NOTE: Lambda
*/

// Python スクリプト ZIP 化
data "archive_file" "ecr_enhanced_scanning_finding" {
  type        = "zip"
  source_file = "${path.module}/lambda/source/ecr_enhanced_scanning_finding_notice.py"
  output_path = "${path.module}/lambda/bin/ecr_enhanced_scanning_finding_notice.zip"
}

// Lambda 
resource "aws_lambda_function" "ecr_enhanced_scanning_finding" {
  filename                       = data.archive_file.ecr_enhanced_scanning_finding.output_path
  function_name                  = "${var.common.project}-${var.common.environment}-ecr-enhanced-scan-finding-notice-function"
  description                    = "${var.common.project}-${var.common.environment}-ecr-enhanced-scan-finding-notice-function"
  role                           = aws_iam_role.lambda_role.arn
  handler                        = "ecr_enhanced_scanning_finding_notice.lambda_handler"
  source_code_hash               = data.archive_file.ecr_enhanced_scanning_finding.output_base64sha256
  reserved_concurrent_executions = -1
  runtime                        = "python3.7"
  environment {
    variables = {
      channelName         = var.channel_name
      kmsEncryptedHookUrl = var.kms_encrypted_hookurl
    }
  }

  lifecycle {
    ignore_changes = [
      environment
    ]
  }

}

// SNS
resource "aws_lambda_permission" "ecr_enhanced_scanning_finding" {
  statement_id  = "AllowExecutionFromSNS"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.ecr_enhanced_scanning_finding.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.ecr_enhanced_scanning_finding.arn
}

// SNS Subscription
resource "aws_sns_topic_subscription" "ecr_enhanced_scanning_finding" {
  topic_arn = aws_sns_topic.ecr_enhanced_scanning_finding.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.ecr_enhanced_scanning_finding.arn
}

Lambda

Lambda のソースコード ( Python ) は、以下のようなディレクトリ構成で配置します。

.
├── eventbridge.tf
├── lambda
│   ├── bin
│   │   └── ecr_enhanced_scanning_finding_notice.zip
│   └── source
│       └── ecr_enhanced_scanning_finding_notice.py
├── lambda.tf
└── variables.tf

現状、Terraform には環境変数を KMS キーで暗号化する機能が見当たりませんでした。
そのため、手動で暗号化するか、暗号化しないかのどちらでも対応できるように実装しておきます。
import boto3
import json
import logging
import os

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from urllib.parse import quote

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):

    CHANNEL_NAME = os.environ['channelName']

    if  "hooks.slack.com" in os.environ['kmsEncryptedHookUrl']:
        logger.info("kmsEncryptedHookUrl: " + str(os.environ['kmsEncryptedHookUrl']))
        logger.info("kmsEncryptedHookUrl is not Encrypted")
        UNENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
        HOOK_URL = "https://" + quote(UNENCRYPTED_HOOK_URL)
    else:
        logger.info("kmsEncryptedHookUrl is Encrypted")
        logger.info("kmsEncryptedHookUrl: " + str(os.environ['kmsEncryptedHookUrl']))
        ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']
        HOOK_URL = "https://" + boto3.client('kms').decrypt(
            CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL),
            EncryptionContext={'LambdaFunctionName': os.environ['AWS_LAMBDA_FUNCTION_NAME']}
        )['Plaintext'].decode('utf-8')

    logger.info("Event: " + str(event))
    message = json.loads(event['Records'][0]['Sns']['Message'])
    logger.info("Message: " + str(message))

    resources = message['resources'][0]
    title = message['title']
    severity = message['severity']
    inspector_score = message['inspectorScore']
    source_url = message['sourceUrl']

    # MEMO: <!channel>, <@user_id>
    slack_message = {
        "channel": CHANNEL_NAME,
        "icon_emoji": ":rotating_light:",
        "attachments": [
            {
                "color": "#FF0000",
                "title": "ECR イメージスキャン結果に脆弱性が存在します",
                "title_link": "https://ap-northeast-1.console.aws.amazon.com/ecr/repositories?region=ap-northeast-1",
                "text": "<!here> \n *Resources* : %s \n" % (resources),
                "fields": [
                    {
                        "title": "Title",
                        "value": title,
                        "short": True
                    },
                    {
                        "title": "Severity",
                        "value": severity,
                        "short": True
                    },
                    {
                        "title": "Inspector_score",
                        "value": inspector_score,
                        "short": True
                    },
                    {
                        "title": "Source_url",
                        "value": source_url,
                        "short": True
                    }
                ]

            }
        ]
    }

    logger.info("HOOK_URL: " + str(HOOK_URL))
    logger.info("CHANNEL_NAME: " + str(CHANNEL_NAME))

    req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))

    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

Terraform ですが思っていたよりも Lambda 周りのデプロイツールとして優秀なんじゃないかと思いました。
わあい。

【Terraform】Inspector V2 の ECR 拡張スキャン結果を Slack に通知する

こんにちは。
ECR の拡張スキャン結果を Slack に通知するリソースを Terraform で書いてみました。
※ 通知内容は Lambda でパースするので、極論 EventBridge の Input Transformer は不要です。

/**
# NOTE: Enhanced-Scanning EventBridge
*/

// 拡張スキャン Finding 実行結果のイベントルール
resource "aws_cloudwatch_event_rule" "ecr_enhanced_scanning_finding_event_rule" {
  name        = "${var.project}-${var.environment}-ecr-enhanced-scanning-finding-event-rule"
  description = "${var.project}-${var.environment}-ecr-enhanced-scanning-finding-event-rule"

  event_pattern = <<EOF
{
  "source": ["aws.inspector2"],
  "detail-type": ["Inspector2 Finding"],
  "detail": {
    "status": ["ACTIVE"],
    "severity": ["MEDIUM", "HIGH", "CRITICAL"]
  }
}
EOF
}

// 拡張スキャン Finding 実行結果の SNS 通知ルール
resource "aws_cloudwatch_event_target" "ecr_enhanced_scanning_finding_event_target" {
  rule      = aws_cloudwatch_event_rule.ecr_enhanced_scanning_finding_event_rule.name
  target_id = "${var.project}-${var.environment}-ecr-enhanced-scanning-finding-notification"
  arn       = aws_sns_topic.ecr_enhanced_scanning_finding.arn

  // JSON を整形して出力
  input_transformer {
    input_paths = {
      resources = "$.resources",
      firstobservedat = "$.detail.firstObservedAt",
      lastobservedat = "$.detail.lastObservedAt",
      updatedat = "$.detail.updatedAt",
      title = "$.detail.title",
      status = "$.detail.status",
      severity = "$.detail.severity",
      inspectorscore   = "$.detail.inspectorScore",
      sourceurl   = "$.detail.packageVulnerabilityDetails.sourceUrl",
    }
    input_template = <<EOF
{
  "resources": <resources>,
  "firstObservedAt": <firstobservedat>,
  "lastObservedAt": <lastobservedat>,
  "updatedAt": <updatedat>,
  "title": <title>,
  "status": <status>,
  "severity": <severity>,
  "inspectorScore": <inspectorscore>,
  "sourceUrl": <sourceurl>
}
EOF
  }
}

/**
# NOTE: Enhanced-Scanning SNS
*/

// 拡張スキャン Finding 実行結果の SNS 通知トピック
resource "aws_sns_topic" "ecr_enhanced_scanning_finding" {
  name = "ecr-enhanced-scanning-finding-notification"
}

// 拡張スキャン Finding 実行結果の SNS 通知トピック ポリシー
resource "aws_sns_topic_policy" "ecr_enhanced_scanning_finding" {
  arn    = aws_sns_topic.ecr_enhanced_scanning_finding.arn
  policy = data.aws_iam_policy_document.eventbridge_to_sns_finding.json
}

// SNS Finding Publish ポリシー
data "aws_iam_policy_document" "eventbridge_to_sns_finding" {
  statement {
    effect  = "Allow"
    actions = ["SNS:Publish"]

    principals {
      type        = "Service"
      identifiers = ["events.amazonaws.com"]
    }

    resources = [aws_sns_topic.ecr_enhanced_scanning_finding.arn]
  }
}

/**
# NOTE: IAM Role For Enhanced-Scanning Lambda
*/

// Lambda role
resource "aws_iam_role" "enhanced_scanning_lambda_role" {
  name               = "${var.project}-${var.environment}-ecr-enhanced-scanning-role"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

// Lambda 基本実行 ポリシー アタッチ
resource "aws_iam_role_policy_attachment" "lambda_execution" {
  role       = aws_iam_role.enhanced_scanning_lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

// CloudWatch ReadOnly ポリシー アタッチ
resource "aws_iam_role_policy_attachment" "lambda_to_cw" {
  role       = aws_iam_role.enhanced_scanning_lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess"
}

// SSM パラメータ 読み込み ポリシー
resource "aws_iam_policy" "lambda_to_ssm" {
  name = "${var.project}-${var.environment}-ecr-enhanced-scanning-policy"
  path = "/"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ssm:GetParameters",
        "secretsmanager:GetSecretValue",
        "kms:Decrypt"
      ],
      "Resource": "*"
    }
  ]
}
EOF
}

// SSM パラメータ 読み込み ポリシー アタッチ
resource "aws_iam_role_policy_attachment" "lambda_to_ssm" {
  role       = aws_iam_role.enhanced_scanning_lambda_role.name
  policy_arn = aws_iam_policy.lambda_to_ssm.arn
}

// 環境変数暗号化 KMS
resource "aws_kms_key" "enhanced_scanning_lambda" {
  description             = "${var.project}-${var.environment}-ecr-enhanced-scanning-lambda-kms"
  deletion_window_in_days = 7
  enable_key_rotation     = true
  is_enabled              = true
}

// 環境変数暗号化 KMS Alias
resource "aws_kms_alias" "enhanced_scanning_lambda" {
  name          = "alias/${var.project}/${var.environment}/ecr_enhanced_scanning_lambda_kms_key"
  target_key_id = aws_kms_key.enhanced_scanning_lambda.id
}

Lambda

EventBridge から SNS に通知されたことをトリガーに、データをパースして Slack の API を叩く Lambda を用意します。
※ EventBridge から直接 Lambda を叩くことも可能です。
今回は AWS にてデフォルトで用意されている cloudwatch-alarm-to-slack-python を調整して使用します。

Lambda > 関数 > 関数の作成 > 設計図の使用 > cloudwatch-alarm-to-slack-python

・既存のロールを使用する で Terraform で作成したロールを選択

・SNS トピック で Terraform で作成した SNS トピックを選択

・環境変数 の kmsEncryptedHookUrl に Slack のペイロード URL を定義
※ https:// のスキーマは除く

・転送時の暗号化 を選択し、Terraform で作成した KMS キーで暗号化

※ 06/17 追記: Lambda も Terraform でデプロイする記事を投稿しました。

【Terraform】 ECR 拡張スキャン通知用の Lambda を自動デプロイしてみる

<CHANNEL_NAME> を通知する Slack ルームの名称に置換して下記コードに置き換えます。

import boto3
import json
import logging
import os

from base64 import b64decode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from urllib.parse import quote

ENCRYPTED_HOOK_URL = os.environ['kmsEncryptedHookUrl']

HOOK_URL = "https://" + boto3.client('kms').decrypt(
    CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL),
    EncryptionContext={'LambdaFunctionName': os.environ['AWS_LAMBDA_FUNCTION_NAME']}
)['Plaintext'].decode('utf-8')

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    logger.info("Event: " + str(event))
    message = json.loads(event['Records'][0]['Sns']['Message'])
    logger.info("Message: " + str(message))

    resources = message['resources'][0]
    title = message['title']
    severity = message['severity']
    inspector_score = message['inspectorScore']
    source_url = message['sourceUrl']

    slack_message = {
        'channel': "#<CHANNEL_NAME>",
        'text': "<!here> \n ■ <https://ap-northeast-1.console.aws.amazon.com/ecr/repositories?region=ap-northeast-1 | *ECR*> : _スキャン結果に脆弱性が存在します。_ \n *Resources* : %s \n *Title* : %s \n *Severity* : %s \n *Inspector_score* : %s \n *Source_url* : %s \n" % (resources, title, severity, inspector_score, source_url)
    }


    logger.info("HOOK_URL: " + str(HOOK_URL))
    req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

動作検証

実際にコンテナイメージを ECR に push して動作を確認します。

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com

DOCKER_BUILDKIT=1 docker build . -f Dockerfile -t src_app
docker tag src_app xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/sanuki-inspection-repository
docker push xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/sanuki-inspection-repository

下記のように Slack にメッセージが通知されれば成功です。
ECR の管理画面から詳細な情報を確認することも可能です。