DockerHubのイメージをGitHub ActionsからECRでスキャンする

DockerHubのコンテナイメージは出自が分からないものも多く、特に企業が利用するには少し不安が残る。
またいつ公開停止になるかも分からないし、自前のリポジトリに移して利用したいケースは多いと思う。
ここではコンテナイメージ利用者はDockerHubのイメージは直接参照せず、脆弱性が見つからなかったイメージのみをECRから使ってもらうようなユースケースを想定し、以下を実現するGitHub Actionsのフローを作成する。
```mermaid
flowchart TD
    A[トリガー] --> B[DockerHubからイメージをPull]
    B --> D[ECRにログイン]
    D --> E[ECRにイメージをPush]
    E --> F[イメージの脆弱性スキャン]
    F --> G{脆弱性を検知？}
    G -- No --> H[Success: 正常終了]
    G -- Yes --> I[イメージをECRから削除]
    I --> J[Fail: 異常終了]
```

なお、ECRではリポジトリに対して`scanOnPush=true`を設定することでPush時に自動的にスキャンさせることも可能だが、設定されていることは期待せずに手動でスキャンを実行する形にする。

## 前提
以下を前提とする
- CI/CDはGitHub Actionsを利用し、Runnerは起動済み
- 以下の環境変数は作業端末内で設定済みとする。
	- `export AWS_REGION=us-east-1`
	- `export AWS_ACCESS_KEY_ID="ASIAV..."`
	- `export AWS_SECRET_ACCESS_KEY="Fh4E..."`
	- `export AWS_SESSION_TOKEN="IQoJ..."`
- [GitHub CLI](https://github.com/cli/cli)をインストール済み

## ECRのリポジトリの準備

ECRのリポジトリを作成する。
```bash
REPOSITORY_NAME=dockerhub-migration/kong-gateway
aws ecr create-repository --repository-name $REPOSITORY_NAME --region $AWS_REGION
```
今回はイメージの対象を[kong/kong-gateway](https://hub.docker.com/r/kong/kong-gateway)とし、tag:3.4.0.0と3.4.3.18で動作検証する。


## パイプラインの作成

カレントディレクトリにgitのcloneがあることを前提として進める。
最初にGitリポジトリにAWSを操作するためのSecretを作成する。
```bash
gh secret set AWS_ACCESS_KEY_ID --body $AWS_ACCESS_KEY_ID
gh secret set AWS_SECRET_ACCESS_KEY --body $AWS_SECRET_ACCESS_KEY
gh secret set AWS_SESSION_TOKEN --body $AWS_SESSION_TOKEN
```
また、コンテナリポジトリに関する設定を同様にVariablesとして設定する。
```bash
gh variable set AWS_REGION --body "us-east-1"
gh variable set REPO_NAME_DOCKER --body "kong/kong-gateway"
gh variable set IMAGE_TAG_DOCKER --body "3.4.0.0"
gh variable set REPO_NAME_ECR --body "dockerhub-migration/kong-gateway"
gh variable set IMAGE_TAG_ECR --body "latest"
```
ECR側のTagを`latest`にしているが、セキュリティ観点からは同じTag名にした方がいいと思う。
今回は利便性の都合上`latest`にしている。
これらはパイプラインからECRにアクセスする際に利用される。

次にGitHub Actionsで使うファイルを作成するためにディレクトリを作成する。
```sh
mkdir -p .github/workflows
```
パイプラインを作成してpushする。
```yaml
cat <<'EOF' > ./.github/workflows/dockerhub2ecr.yaml
name: Push and Scan ECR Image

on:
  workflow_dispatch:

jobs:
  build-and-scan:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Log in to Amazon ECR
        id: login-ecr
        run: |
          set -x
          ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text)
          echo "ACCOUNT_ID=$ACCOUNT_ID" >> $GITHUB_ENV
          ECR_URI="${ACCOUNT_ID}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com/${{ vars.REPO_NAME_ECR }}"
          echo "ECR_URI=$ECR_URI" >> $GITHUB_ENV
          aws ecr get-login-password --region ${{ vars.AWS_REGION }} | docker login --username AWS --password-stdin "$ECR_URI"

      - name: Pull and Tag Docker image
        run: |
          set -x
          docker pull ${{ vars.REPO_NAME_DOCKER }}:${{ vars.IMAGE_TAG_DOCKER }}
          docker tag ${{ vars.REPO_NAME_DOCKER }}:${{ vars.IMAGE_TAG_DOCKER }} $ECR_URI:${{ vars.IMAGE_TAG_ECR }}

      - name: Push Docker image to ECR
        run: |
          docker push $ECR_URI:${{ vars.IMAGE_TAG_ECR }}

      - name: Start ECR image scan
        run: |
          aws ecr start-image-scan \
            --repository-name ${{ vars.REPO_NAME_ECR }} \
            --image-id imageTag=${{ vars.IMAGE_TAG_ECR }}

      - name: Wait and check scan results
        run: |
          for i in {1..10}; do
            result=$(aws ecr describe-image-scan-findings \
              --repository-name ${{ vars.REPO_NAME_ECR }} \
              --image-id imageTag=${{ vars.IMAGE_TAG_ECR }})

            status=$(echo "$result" | jq -r '.imageScanStatus.status')
            if [[ "$status" == "COMPLETE" ]]; then
              echo "Scan complete."
              echo "$result" > scan-results.json
              break
            fi
            echo "Waiting for scan to complete... ($i)"
            sleep 10
          done

          CRITICALS=$(jq '[.imageScanFindings.findings[] | select(.severity == "CRITICAL")] | length' scan-results.json)
          if [ "$CRITICALS" -gt 0 ]; then
            echo "Vulnerabilities found: CRITICAL=$CRITICALS"
            echo "Deleting image..."
            aws ecr batch-delete-image --repository-name ${{ vars.REPO_NAME_ECR }} \
              --image-ids imageTag=${{ vars.IMAGE_TAG_ECR }}
            exit 1
          else
            echo "No critical vulnerabilities found."
          fi

      - name: Upload scan results as artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: scan-results
          path: scan-results.json
EOF
git add -A
git commit -m "Initial Commit"
git push origin main
```
以下、GitHub Actionsのフローについて説明する。
```yaml
on:
  workflow_dispatch:
```
実行契機について指定する箇所だが、対象コードなどを含まない構造にしているので手動実行である`workflow_dispatch`のみ設定している。


```yaml
      - name: Checkout code
        uses: actions/checkout@v4
```
上記はRunnerがソースコードを引っ張る処理である。
通常はビルドのためのコードなどを引っ張るために利用するが、今回に関しては不要。

```yaml
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
          aws-region: ${{ vars.AWS_REGION }}
```
上記はAWSを操作するための環境変数を設定する箇所となる。
詳細は以下の記事が参考になる。
-　[ configure-aws-credentialsを読み解く](https://qiita.com/ezqy/items/a01cdce5fe6ad21ced35)

```yaml
      - name: Log in to Amazon ECR
        id: login-ecr
        run: |
          set -x
          ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text)
          echo "ACCOUNT_ID=$ACCOUNT_ID" >> $GITHUB_ENV
          ECR_URI="${ACCOUNT_ID}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com/${{ vars.REPO_NAME_ECR }}"
          echo "ECR_URI=$ECR_URI" >> $GITHUB_ENV
          aws ecr get-login-password --region ${{ vars.AWS_REGION }} | docker login --username AWS --password-stdin "$ECR_URI"
```
上記はECRへのログインである。最初にECRのURLを組み立てるために`aws sts get-caller-identity`でアカウントIDを取得し、`ECR_URI`という環境変数にECRのアクセス先を定義する。
次に` aws ecr get-login-password`でECRにログインするためのパスワードを取得し、`docker login`でログインしてpush出来る状態にしている。
なお、ECRのURIはこの先も利用するが、毎回アカウントIDを取得するのは大変なので、`"ECR_URI=$ECR_URI" >> $GITHUB_ENV`で`GITHUB_ENV` に環境変数を追加することで別のstepでも参照できるようにしている。

```yaml
      - name: Push Docker image to ECR
        run: |
          docker push $ECR_URI:${{ vars.IMAGE_TAG_ECR }}

      - name: Start ECR image scan
        run: |
          aws ecr start-image-scan \
            --repository-name ${{ vars.REPO_NAME_ECR }} \
            --image-id imageTag=${{ vars.IMAGE_TAG_ECR }}
```
上記でイメージをPushし、スキャンを開始している。
`aws ecr start-image-scan`ではスキャンの開始の指示のみを行い、コマンドはスキャンの完了を待たない。
待つオプションも[なさそう](https://docs.aws.amazon.com/cli/latest/reference/ecr/start-image-scan.html)なので、次のstepで状態を監視する。
なお、AWS的なベストプラクティスとしてはスキャン結果の通知を[EventEventBridge経由で受け取り](https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/ecr-eventbridge.html)、その後ゴニョゴニョするのが良さそうなのだが、個人的には同一のパイプラインの処理の中で完結させたかったのと、ベンダロックインを多少は避ける意味でも採用しないこととした。


```yaml
      - name: Wait and check scan results
        run: |
          for i in {1..10}; do
            result=$(aws ecr describe-image-scan-findings \
              --repository-name ${{ vars.REPO_NAME_ECR }} \
              --image-id imageTag=${{ vars.IMAGE_TAG_ECR }})

            status=$(echo "$result" | jq -r '.imageScanStatus.status')
            if [[ "$status" == "COMPLETE" ]]; then
              echo "Scan complete."
              echo "$result" > scan-results.json
              break
            fi
            echo "Waiting for scan to complete... ($i)"
            sleep 10
          done
```
上記では`aws ecr describe-image-scan-findings`の結果からスキャンの状態をチェックし、`COMPLETE`なら結果を`scan-results.json`に保存し、そうでない場合は10秒待って再チェック（最大10回）する処理となっている。
ここの待ち時間等はお好みで調整すると良い。

```yaml
          CRITICALS=$(jq '[.imageScanFindings.findings[] | select(.severity == "CRITICAL")] | length' scan-results.json)
          if [ "$CRITICALS" -gt 0 ]; then
            echo "Vulnerabilities found: CRITICAL=$CRITICALS"
            echo "Deleting image..."
            aws ecr batch-delete-image --repository-name ${{ vars.REPO_NAME_ECR }} \
              --image-ids imageTag=${{ vars.IMAGE_TAG_ECR }}
            exit 1
          else
            echo "No critical vulnerabilities found."
          fi
```
上記ではCRITICALがあればエラーに倒してパイプラインも終了するようにしている。
またその際にPushしたイメージも誤って利用されるのを避けるために削除するようにしている。
HIGHでも引っ掛けたい場合は同じような書き方でHIGHも引っ掛けてif文の条件に追加すると良い。

```yaml
      - name: Upload scan results as artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: scan-results
          path: scan-results.json
```
最後に結果をArtifactとしてアップロードして終了する。

## 動作確認
今の設定は`kong/kong-gateway:3.4.0.0`に対してスキャンする設定となっている。
一旦この設定で動かしてみる。
GitHubの`Actions`から`Run workflow`をクリックして実行する。
![Pasted image 20250507155614.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/6eab87ee-99ff-4fb5-94ce-1f014267f284.png)


実行するとパイプラインは失敗する。
エラーを見ると以下のようになっている。
![Pasted image 20250507160926.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/15b1d2a6-b031-49df-b5bc-bb216e5651a9.png)

CRITICALの脆弱性が23個検出されたため、パイプラインが停止したことが分かる。
ECR上にもイメージは存在しない。
```bash
 $ aws ecr describe-images \
   --repository-name dockerhub-migration/kong-gateway
{
    "imageDetails": []
}
```
イメージのバージョンが古かったため脆弱性が検出されたと考えられるので、次にイメージのバージョンを最新にして試してみる。
リポジトリ内のVariablesを変更する。
```bash
gh variable set IMAGE_TAG_DOCKER --body "3.4.3.18"
```
変更後、Actionsのジョブを再実行する。
次は問題なく成功する。
![Pasted image 20250507161454.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/e5da199f-2e02-4dd0-9846-3ed0ee8e5a26.png)

スキャン結果はArtifactとしてアップロードされたJSONからも確認できるし、ECRのUI上からも確認できる。
![Pasted image 20250507162039.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/b8a54400-59b7-4ada-8ffa-e231b08af89f.png)

## 余談
スキャンを何度か実行すると以下のエラーが出るようになる。
```
An error occurred (LimitExceededException) when calling the StartImageScan operation (reached max retries: 2): The scan quota per image has been exceeded. Wait and try again.
```
[こちら](https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/APIReference/API_StartImageScan.html)を見る感じ、1イメージあたり24時間につき1回、全体で24時間につき10万回スキャンが可能であり、1イメージを短時間で何度もスキャンするのは許されていない模様。
Quotaを気にするのが嫌な人は毎回イメージを削除してPushし直すか、イメージのPush前にTrivyでスキャンするようにし、そこで問題なかったらPushするような形に書き換えるとよい。
（ECRはイメージの格納のみでスキャンは利用しない）