AWS ECRスキャンを定期実行させる

# イントロダクション
ECRには、コンテナイメージ内の脆弱性を検出するイメージスキャン機能があり、この機能はAmazon InspectorのCoreOS Clairスキャナに基づいており、NVD(National Vulnerability Database)とALAS(Amazon Linux Security Advisory Database)から得られるデータを用いて、多数のOSと言語で使用されているパッケージに対する脆弱性を検出する。この記事ではそのスキャン機能を定期実行させます。

# 前提
スキャンは以下の2種類がある。後述するStartImageScanは拡張スキャンに対応してないので、定期実行はベーシックスキャンのみが可能。（拡張スキャンが有効化されている状況でベーシックスキャンを手動実行すると拡張スキャンを手動実行したということになり、<b>"This feature is disabled"</b>となる）

1. ベーシックスキャン：ベーシックスキャンは無料で提供され、NVDとALASに基づいて脆弱性を検出する。
1. 拡張スキャン：拡張スキャンは有料で、追加の脆弱性データベースと脆弱性評価を提供する。これには、商用製品に対する脆弱性の詳細やプライオリティ付けの情報も含まれる。
- 同一リージョンにおいては、ベーシックスキャンか拡張スキャンのどちらか一方のみが利用が可能。
- Terraform v1.4.6 on darwin_amd64

# 本文
## 背景
AWSのShared Responsibility Model（共有責任モデル）によれば、AWSはセキュリティ<b>「of」the cloud</b>を担当し、私達顧客はセキュリティ<b>「in」the cloud</b>を担当します。
今回のケースでは、Privateリポジトリで管理するDockerイメージを運用するので、イメージの保守管理はユーザ側の責任となります。そのため、保守範囲にベースイメージの更新やアプリケーション依存性の更新、必要に応じてセキュリティパッチの適用などが含まれるので、スキャン自体はベーシックスキャンと拡張スキャンの両方を交互に適宜実施して運用する必要があります。


## 詳細説明
### 概要
- AWS ECR(Elastic Container Registry)のイメージスキャン結果を、Slackに通知する方法について説明します。

### フロー
1. EventBridgeのルールを定義し、ECRのイメージスキャン完了をトリガーとします。このルールでは、特定のイベントパターン（ここではECRイメージスキャン結果）をキャプチャします。
1. SNS(Simple Notification Service)を使用して、EventBridgeからのイベントを受け取ります。SNSは、イベントをキャプチャし、通知します。
1. AWS Chatbotを使用して、SNSトピックを指定します。これにより、SNSからのメッセージがChatbotに転送されます。
1. 最後に、Chatbotが受け取ったメッセージをSlackに転送します。
 
### スキャンのスケジューリング
- スキャン対象イメージがあるレポジトリはpush時にスキャン実行しないようにしておきます。
- EventBridgeのスケジューリング機能を使用して、スキャンの実行頻度を定義します。これにより、定期的にECRイメージスキャンが実行され、結果がSlackに通知されます。


## 手順
<summary>locals.tf</summary>
<details>

```hcl
locals {
  basicscan_event_pattern = {
    "source"      = ["aws.ecr"]
    "detail-type" = ["ECR Image Scan"]
    "detail" = {
      "scan-status"     = ["COMPLETE"]
      "repository-name" = ["example"]
    }
  }
}
/*
// 任意のimage-tagを持つイメージのみ対象とする場合
{
  "source": ["aws.ecr"],
  "detail-type": ["ECR Image Scan"],
  "detail": {
    "repository-name": ["example"],
    "scan-status": ["COMPLETE"],
    "image-tags": ["xxxx-xxxx-latest", "xxxx-xxxx-latest"]
  }
}
*/

locals {
  basicscan_event_schedules = {
    main_web = {
      name              = "example-web-latest"
      repository-name   = "example"
      ImageTag          = "web-latest"
    }
    main_proxy = {
      name            = "example-proxy-latest"
      repository-name = "example"
      ImageTag        = "proxy-latest"
    }
  }
}

locals {
  scheduler_schedule_group_name = "example"
}
```
</details>

<summary>iam_role.tf</summary>
<details>

```hcl
data "aws_ecr_repository" "example" {
  name = "example"
}

resource "aws_iam_role" "example" {
  name = "scan-ecr"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Principal = {
          Service = [
            "events.amazonaws.com",
            "scheduler.amazonaws.com",
          ]
        }
        Effect = "Allow"
      }
    ]
  })
}

resource "aws_iam_role_policy" "example" {
  name = "scan-ecr"
  role = aws_iam_role.example.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "ecr:StartImageScan",
          "ecr:DescribeImageScanFindings",
        ]
        Effect   = "Allow"
        Resource = data.aws_ecr_repository.example.arn
      }
    ]
  })
}
```
</details>

<summary>sns_topoic.tf</summary>
<details>

```hcl
data "aws_caller_identity" "current" {}

resource "aws_sns_topic" "example" {
  name = "example"
}

data "aws_iam_policy_document" "example" {
  statement {
    effect = "Allow"

    principals {
      type        = "AWS"
      identifiers = ["*"]
    }

    actions = [
      "SNS:GetTopicAttributes",
      "SNS:SetTopicAttributes",
      "SNS:AddPermission",
      "SNS:RemovePermission",
      "SNS:DeleteTopic",
      "SNS:Subscribe",
      "SNS:ListSubscriptionsByTopic",
      "SNS:Publish",
    ]

    resources = [aws_sns_topic.example.arn]

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceOwner"
      values   = [data.aws_caller_identity.current.account_id]
    }
  }

  statement {
    effect = "Allow"

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

    actions   = ["sns:Publish"]
    resources = [aws_sns_topic.example.arn]
  }
}
```
</details>

<summary>cloudwatch_event.tf</summary>
<details>

```hcl
resource "aws_cloudwatch_event_rule" "example" {
  name          = "example"
  description   = "Triggers when an ECR image scan is completed for a specific repository"
  event_pattern = jsonencode(local.basicscan_event_pattern)
}

resource "aws_cloudwatch_event_target" "example" {
  rule      = aws_cloudwatch_event_rule.example.name
  target_id = "SendToSlack"
  arn       = aws_sns_topic.example.arn
}
```
</details>

<summary>scheduler.tf</summary>
<details>

```hcl
resource "aws_scheduler_schedule_group" "example" {
  name = local.scheduler_schedule_group_name

  tags = {
    Name = local.scheduler_schedule_group_name
  }
}

resource "aws_scheduler_schedule" "example" {
  for_each = local.basicscan_event_schedules

  description                  = "ベーシックスキャンを週1で実行"
  group_name                   = "example"
  name                         = each.value.name
  schedule_expression          = "rate(7 days)"
  schedule_expression_timezone = "Asia/Tokyo"
  start_date                   = "2023-05-24T06:10:00Z"
  state                        = "ENABLED"

  flexible_time_window {
    maximum_window_in_minutes = 15
    mode = "FLEXIBLE"
  }

  target {
    arn = "arn:aws:scheduler:::aws-sdk:ecr:startImageScan"
    input = jsonencode(
      {
        ImageId = {
          ImageTag = each.value.ImageTag
        }
        RepositoryName = each.value["repository-name"]
      }
    )
    role_arn = aws_iam_role.example.arn

    retry_policy {
      maximum_event_age_in_seconds = 86400 //24h
      maximum_retry_attempts       = 185
    }
  }
}
```
</details>

---
## 通知確認
![スクリーンショット 2023-05-24 18.40.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58788/2bd842c3-095b-ec52-ea69-bed13d8868dd.png)