【Wireshark】ルータを経由する HTTPS 通信の内容を tcpdump でキャプチャして復号してみる

こんにちは。
サーバの秘密鍵が漏洩すると HTTPS 通信の内容が復号されてしまうことは知識として理解していますが、いざ実際に復号するには、どのようにすればいいのか調べてみました。

今回はクライアント ( Client ) 、ルータ ( Router ) 、サーバ ( Server ) の 3 ホストを仮想環境に用意して、順番に検証してみようと思います。

検証方法

以下の図のように、Router でキャプチャした暗号化されたパケットを、Server の秘密鍵、Client のセッションキーを用いて復号化することをゴールとします。
パケットキャプチャには tcpdump を、パケットの復号化には Wireshark を用います。

環境構築

各種ホストは VirtualBox 上に起動した AlmaLinux9 にて構築します。

RoleIP addressOSSupplement
Router192.168.33.11AlmaLinux 9
Client192.168.33.12AlmaLinux 9
Server192.168.33.13AlmaLinux 9vhosts: verienv.com

Router

Router は iptables を用いて構築し、且つ、192.168.33.0/24 ネットワークからのパケットをフォワードするように設定します。

systemctl stop firewalld
systemctl disable firewalld
dnf -y install iptables-services tcpdump
iptables -t nat -A POSTROUTING -s 192.168.33.0/24 -j MASQUERADE
iptables -A FORWARD -s 192.168.33.0/24 -j ACCEPT
iptables -A FORWARD -d 192.168.33.0/24 -j ACCEPT
iptables-save > /etc/sysconfig/iptables
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf
sysctl -p

Client

Client では、元々のルーティングテーブルのエントリを削除し、先の Router をデフォルトゲートウェイとするエントリを追加します。
また、verienv.com の名前解決先が Server に向くように hosts ファイルにエントリを追加します。

dnf -y install net-tools
route delete default
route add default gw 192.168.33.11
route del -net 192.168.33.0 netmask 255.255.255.0
echo "192.168.33.13 verienv.com" >> /etc/hosts

Server

Server には特別な設定は必要なく、一般的な 443 を Listen する WEB サーバを構築します。
設定した証明書の秘密鍵は手元に落としておきます。
※ 本検証では verienv.com という FQDN のバーチャルホストを切り使用します。
※ ありふれた設定なので、本記事では割愛します。

検証

キャプチャ

Router にて以下コマンドを実行し、192.168.33.0/24 からのパケットをキャプチャして capture.pcap というファイルに出力します。

tcpdump -i eth1 -s 0 net 192.168.33.0/24 -w capture.pcap

Client にて以下コマンドを実行し、Server に対して JSON の POST リクエストを発行します。
※ SSLKEYLOGFILE 環境変数には、鍵情報 ( セッションキー ) のダンプ先となるファイルパスを指定しており、今回はカレントディレクトリの key.log というファイルに出力します。

SSLKEYLOGFILE=./key.log curl -k -X POST -H "Content-Type: application/json" -d '{"name": "snkk1210", "age": 100, "city": "New York"}' https://verienv.com

Router のフォアグラウンドで実行されている tcpdump を 「Ctrl + c」 で終了させ、Router の capture.pcap と Client の key.log を手元に落とします。

復号化

暗号化されたパケットの復号には Wireshark を使います。
一旦、手元に落とした capture.pcap を Wireshark で開いてみます。

この状態だとパケットは暗号化されたままで内容が分からないですね…。
以下の手順で Wireshark に先の鍵情報を登録します。

Edit > Preferences > Protocols > TLS > RSA keys list [Edit]

KeyValue
IP address192.168.33.13
Port443
Protocolhttp
Key File< Server の秘密鍵のパス >
Password

実は Perfect Forward Secrecy ( PFS ) が有効である場合や、暗号スイートに DHE を使っている場合、秘密鍵だけだと復号ができません。

同じく (Pre)-Master-Secret log filename に手元に落とした key.log ( セッションキー ) を指定します。

この状態でパケットの中身を覗いてみます。


新たに Decrypted TLS というタブが追加されており、こちらを選択することでリクエストの中身を確認することができました。

終わりに

今回、復号処理は全て Wireshark で行いましたが、この辺りを深く理解して独自に実装できるようになりたいものです。
もっと勉強しなくちゃですね。

【CI/CD】tfnotify と GitHub App で Terraform の CI 機構を作ってみる【CodeBuild】

こんにちは。お久しぶりです。
新しい会社に移ってから、約半年が経過しました。
周りの皆様はとてもレベルが高く、大変素晴らしい環境でお仕事をさせていただいております。
本当に心の底から転職して良かったな、と感じる今日この頃です。

さて、本日は、とあるシステムの構築に携わらせていただき、Terraform の CI 機構を CodeBuild / GitHub App / tfnotify で作る機会があったので、その際の備忘録をツラツラと残しておこうと思います。

作るもの

構成

Terraform のコード自体は GitHub のリポジトリでホストされていることを前提とします。
Pull Request の作成、更新時に CodeBuild を Hook して Terraform を実行し、plan 結果を Pull Request のコメントとして通知します。
詳細なシーケンスは以下の通りです。

① PR の作成、更新をトリガーに CodeBuild を起動。
② CodeBuild が SSM Parameter Store に格納された GitHub App の秘密鍵を取得
③ GitHub App の秘密鍵から JWT を生成し、 API を叩いて Installation ID を生成
④ Installation ID を指定して API を叩き、Access Token を生成
⑤ terraform plan を実行
⑥ plan 結果を tfnotify に渡して Pull Request に通知
⑦ Access Token を無効化

ディレクトリ構成

.
├── deploy_scripts
│   └── ci
│       ├── .tfnotify.yml
│       ├── bin
│       │   ├── create_github_token.sh
│       │   ├── install_terraform.sh
│       │   ├── install_tfnotify.sh
│       │   ├── plan_tfnotify.sh
│       │   └── revoke_github_token.sh
│       └── buildspec.yml
└── env
    └── dev
        ├── resource1
        │   ├── 00_backend.tf
        │   ├── 00_provider.tf
        │   └── vpc.tf
        ├── resource2
        │   ├── 00_backend.tf
        │   ├── 00_provider.tf
        │   └── vpc.tf
        └── resource3
            ├── 00_backend.tf
            ├── 00_provider.tf
            └── vpc.tf

Terraform のコードは /env/dev/resource[1 – 3] 配下の 3 つをエントリーポイントとするディレクトリ構成を前提とします。

また、/deploy_scripts/ci/bin 配下に GitHub App より Token を発行するスクリプトや、Terraform の実行結果を通知するためのユーティリティスクリプトを配置します。

buildspec.yml

CodeBuild の処理を定義する buildspec.yml は以下の通りです。

---
version: 0.2
  
phases:
  install:
    runtime-versions:
      golang: latest
    commands:
      - /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/install_terraform.sh
      - /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/install_tfnotify.sh
  pre_build:
    commands:
      - export APP_ID=$(aws ssm get-parameter --name ${APP_ID_SSM_ARN} --with-decryption --query Parameter.Value --output text)
      - export APP_SECRET=$(aws ssm get-parameter --name ${APP_SECRET_SSM_ARN} --with-decryption --query Parameter.Value --output text)
      - export GITHUB_TOKEN=$(/bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/create_github_token.sh)
      - export REPO_OWNER=$(echo $REPO_FULL_NAME | cut -d '/' -f 1)
      - export REPO_NAME=$(echo $REPO_FULL_NAME | cut -d '/' -f 2)
      - sed -i "s/<OWNER>/$REPO_OWNER/g; s/<NAME>/$REPO_NAME/g" ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/.tfnotify.yml
      - cd ${CODEBUILD_SRC_DIR}
      - terraform fmt -no-color -check -diff -recursive
  build:
    commands:
      - |
        if git --no-pager diff origin/main..HEAD --name-only | grep -E '^env/dev/resource1/'; then
          /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/plan_tfnotify.sh /env/dev/resource1/
        fi
  
      - |
        if git --no-pager diff origin/main..HEAD --name-only | grep -E '^env/dev/resource2/'; then
          /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/plan_tfnotify.sh /env/dev/resource2/
        fi
        
      - |
        if git --no-pager diff origin/main..HEAD --name-only | grep -E '^env/dev/resource3/'; then
          /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/plan_tfnotify.sh /env/dev/resource3/
        fi
  post_build:
    commands:
      - /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/revoke_github_token.sh

CodeBuild の処理自体は、先のシーケンスと同等です。
差分のあったディレクトリの plan 結果のみを通知するように、build ステージで main ブランチとの diff を取ってから、検知したディレクトリのみ Terraform の実行と通知処理を行います。

.tfnotify.yml

GitHub への通知を行うため tfnotify を用います。
通知内容のカスタマイズも可能で、以下のように yaml ファイルへ通知内容を記述し、 CLI 実行時の引数として渡してあげます。
※ <OWNER> と <NAME> は、先の buildspec.yaml の処理 ( 16 – 18 行 ) にて適切なリポジトリ情報で置換されます。

---
ci: codebuild

notifier:
  github:
    token: $GITHUB_TOKEN
    repository:
      owner: "<OWNER>"
      name: "<NAME>"

terraform:
  plan:
    template: |
      {{ .Title }} for {{ .Message }} <sup>[CI link]( {{ .Link }} )</sup>
      {{if .Result}}
      <pre><code> {{ .Result }}
      </pre></code>
      {{end}}
      <details><summary>Details (Click me)</summary>

      <pre><code>
      {{ .Body }}
      </pre></code>
      </details>
    when_add_or_update_only:
      label: "add-or-update"
    when_destroy:
      label: "destroy"
      template: |
        ## :warning: WARNING: Resource Deletion will happen :warning:

        This plan contains **resource deletion**. Please check the plan result very carefully!
    when_plan_error:
      label: "error"

Utility scripts

CLI の導入、Token の発行、通知等々のスクリプトを以下のように用意します。
※ クリックで展開します。

install_terraform.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

set -eux

TFVERSION="1.7.3"

git clone https://github.com/tfutils/tfenv.git $HOME/.tfenv
ln -s $HOME/.tfenv/bin/* /usr/local/bin
tfenv install ${TFVERSION} && tfenv use ${TFVERSION}
terraform version
install_tfnotify.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

set -eux

TNVERSION="v0.8.0"

wget https://github.com/mercari/tfnotify/releases/download/${TNVERSION}/tfnotify_linux_amd64.tar.gz
tar xzf tfnotify_linux_amd64.tar.gz
cp tfnotify /usr/local/bin/
tfnotify
plan_tfnotify.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

set -eux

echo "Run Terraform plan in ${CODEBUILD_SRC_DIR}/${1}/."

cd ${CODEBUILD_SRC_DIR}/${1}/ && \
terraform init && \
terraform plan -no-color | tfnotify --config ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/.tfnotify.yml plan --message "${1}"

echo "Terraform plan executed in ${CODEBUILD_SRC_DIR}/${1}/."
create_github_token.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

base64url() {
  openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
}

sign() {
  openssl dgst -binary -sha256 -sign <(printf '%s' "${APP_SECRET}")
}

header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"

now="$(date '+%s')"
iat="$((now - 60))"
exp="$((now + (3 * 60)))"
template='{"iss":"%s","iat":%s,"exp":%s}'
payload="$(printf "${template}" "${APP_ID}" "${iat}" "${exp}" | base64url)"

signature="$(printf '%s' "${header}.${payload}" | sign | base64url)"

jwt="${header}.${payload}.${signature}"

installation_id="$(curl --location --silent --request GET \
  --url "https://api.github.com/repos/${REPO_FULL_NAME}/installation" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.id'
)"

token="$(curl --location --silent --request POST \
  --url "https://api.github.com/app/installations/${installation_id}/access_tokens" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.token'
)"

echo "${token}"
revoke_github_token.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

curl --location --silent --request DELETE \
  --url "https://api.github.com/installation/token" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${GITHUB_TOKEN}"

AWS Resources

構成図にある CodeBuild 関連のリソースですが、こちら に Terraform の module を用意しました。
以下のように呼び出すことで必要なリソースがプロビジョニングされます。
※ GitHub App の ID と 秘密鍵 は SSM Parameter Store に SecureString として手動で登録しておき、その ARN を CodeBuild の環境変数 ( APP_ID_SSM_ARN, APP_SECRET_SSM_ARN ) として設定しておきます。
※ クリックで展開します。

呼び出しサンプル
module "cicd_terraform_ci_github" {
  source = "git::https://github.com/snkk1210/tf-m-templates.git//modules/aws/cicd/terraform/ci/github"

  common = {
    "project"      = "sample"
    "environment"  = "sandbox"
    "service_name" = "hcl"
    "type"         = "ci"
  }

  source_info = {
    location        = "https://github.com/<ORGANIZATION_NAME>/<REPOSITORY_NAME>.git"
    git_clone_depth = 1
    buildspec       = "./deploy_scripts/ci/buildspec.yml"
  }

  environment_variable = {
    variables = [
      {
        name  = "APP_ID_SSM_ARN"
        value = "arn:aws:ssm:ap-northeast-1:xxxxxxxxxxxx:parameter/path/to/id"
        type  = "PLAINTEXT"
      },
      {
        name  = "APP_SECRET_SSM_ARN"
        value = "arn:aws:ssm:ap-northeast-1:xxxxxxxxxxxx:parameter/path/to/secret"
        type  = "PLAINTEXT"
      },
      {
        name  = "REPO_FULL_NAME"
        value = "<ORGANIZATION_NAME>/<REPOSITORY_NAME>"
        type  = "PLAINTEXT"
      }
    ]
  }

  filter_groups = [
    {
      event_pattern = "PULL_REQUEST_CREATED"
      file_pattern  = "^env/dev/*"
    },
    {
      event_pattern = "PULL_REQUEST_UPDATED"
      file_pattern  = "^env/dev/*"
    },
    {
      event_pattern = "PULL_REQUEST_REOPENED"
      file_pattern  = "^env/dev/*"
    }
  ]
}

やってみる!!

以下のように /env/dev/resource1 配下のリソースを変更 ( Name タグの更新 ) し、Pull Request を作成します。

diff --git a/env/dev/resource1/vpc.tf b/env/dev/resource1/vpc.tf
index 5360514..415e5ae 100644
--- a/env/dev/resource1/vpc.tf
+++ b/env/dev/resource1/vpc.tf
@@ -2,6 +2,6 @@ resource "aws_vpc" "this" {
   cidr_block = "10.10.0.0/16"

   tags = {
-    Name = "dev-resource1-vpc.0.0.11"
+    Name = "dev-resource1-vpc.0.0.12"
   }
 }

CodeBuild が起動しました!

コメントが通知されました!

Terraform の実行結果も確認できました!

と、いった形で Terraform の CI 機構が完成しました!

終わりに

当初は GitHub の PAT ( Personal Access Token ) をそのまま使って機構を作ろうとしておりました。
しかし、セキュリティリスクの懸念があるのではないか?との指摘をいただいて、GitHub App から一時トークンを生成して利用する構成に変更した経緯があります。
これも新しい職場に転職したおかげで気づけた事柄ですね!!

これからも新天地で頑張っていきます!!
※ 本当に転職して良かった (歓喜)。

【AWS】SES で受信したメールを Lambda で加工して Slack に通知するパイプラインを Terraform で作る【Python】

こんにちは。
気づくと既に 2023 年も 1/4 が経過しており、時間の進む速度感覚がおかしくなってきた今日この頃でございます。

さて、とある事情から SES receipt rule で受信したメールを加工して Slack に通知させる必要がでてきました。
具体的には最終的に下記構成のようなパイプラインを構築しようと考えております。

・メール受信時にメール加工用 Lambda を起動し、データを S3 に格納
・特定の From のメールが S3 に格納されたことをトリガーに通知用 Lambda を起動
・別システムのデータベースから情報を引っ張ってきて Slack へ通知

つきましては、この構成の一部を Terraform で管理してみようと思います。

構成図

今回は SES receipt rule → SNS → Lambda → S3 → EventBridge → Lambda までを一括でデプロイできる HCL を作成します。

HCL

作成したコード一式は こちら になります。

.
├── README.md
├── example-slack-lambda.tf.example
├── modules
│   ├── notifier
│   │   ├── eventbridge.tf
│   │   ├── example
│   │   │   ├── basic
│   │   │   │   ├── lambda
│   │   │   │   │   ├── bin
│   │   │   │   │   └── source
│   │   │   │   │       └── basic.py
│   │   │   │   ├── lambda.tf
│   │   │   │   └── variables.tf
│   │   │   └── slack
│   │   │       ├── lambda
│   │   │       │   ├── bin
│   │   │       │   └── source
│   │   │       │       └── slack.py
│   │   │       ├── lambda.tf
│   │   │       └── variables.tf
│   │   └── variables.tf
│   └── receiver
│       ├── lambda
│       │   ├── bin
│       │   └── source
│       │       └── receiver.py
│       ├── lambda.tf
│       ├── s3-processed.tf
│       ├── s3-receiver.tf
│       ├── ses-receipt-rule.tf
│       ├── sns.tf
│       └── variables.tf
├── notifier.tf.example
└── receiver.tf.example

modules/ 配下に各種リソース定義を配置し、上位からモジュールを読み込む想定です。
※ .example はモジュール読み込み用のテンプレートです。

以降、要所をツラツラと記載させて頂きます。

Lambda ( メール加工 )

SES で受信したメールを加工する Lambda を用意します。
処理の詳細はメールの From、受信日、件名、本文、表示名を抜き出し、S3 に格納する、とします。
S3 に格納する情報は、後に繋ぎやすいように、本文のみのオブジェクトとし、その他の情報はオブジェクトを格納するディレクトリ名として保持させます。

・modules/receiver/lambda/source/receiver.py

import boto3
import json
import logging
import email
import random
import string
import os
import re

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'])
    m_from = message['mail']['commonHeaders']['from'][0]
    date = message['mail']['commonHeaders']['date']
    subject = message['mail']['commonHeaders']['subject']
    content = message['content']
    email_obj = email.message_from_string(content)

    body = perth_mail_body(email_obj)
    logger.info("Body: " + str(body))

    fname = extract_mail_address(m_from).replace("/","[slash]") + "/" \
        + subject.replace("/","[slash]") \
        + "/" + date.replace("/","[slash]") \
        + "/" + extract_display_name(m_from) + "[dn]" + randomstr(20) + ".txt"
    logger.info("Fname: " + str(fname))

    res = put2s3(body, fname)
    logger.info("Response: " + str(res))

def perth_mail_body(email_obj):
    """
    Retrieve the body part of the mail.

    Parameters
    ----------
    email_obj : object structure

    Returns
    -------
    body : string
        body part of the mail
    """

    body = ""
    for part in email_obj.walk():
        logger.info("maintype: " + part.get_content_maintype())
        if part.get_content_maintype() == 'multipart':
            continue

        attach_fname = part.get_filename()

    if not attach_fname:
        charset = str(part.get_content_charset())
        if charset:
            body += part.get_payload(decode=True).decode(charset, errors="replace")
        else:
            body += part.get_payload(decode=True)
    else:
        logger.info("There is Attach File")
        body += "Error: Attachments are not supported -> " + str(part.get_payload(decode=True))

    return body

def put2s3(body, fname):
    """
    Upload files to S3.

    Parameters
    ----------
    body : string
        File Contents
    fname : string
        File Name ( Path )

    Returns
    -------
    res : dict
        API Return Values
    """

    s3 = boto3.client("s3")

    try:
        res = s3.put_object(
            Bucket=os.environ['s3BucketName'],
            ACL='private',
            Body=body,
            Key=fname,
            ContentType='text/plain'
            )
        logger.info("Success: %s has been written.", fname)
        logger.info("Success: %s", res)
    except Exception as e:
        logger.error("Error: %s", e)
    return res

def randomstr(n):
    """
    Generate a random string.

    Parameters
    ----------
    n : int
        length of a character string
    
    Returns
    -------
        : string
        random string
    """
    return ''.join(random.choices(string.ascii_letters + string.digits, k=n))

def extract_mail_address(m_from):
    """
    Extracting email addresses from a string.

    Parameters
    ----------
    m_from : string
        String containing an email address
    
    Returns
    -------
        : list
        email addresses 
    """
    pattern = r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+'
    return re.findall(pattern, m_from)[0]

def extract_display_name(m_from):
    """    
    Extracting display name from a Mail From.

    Parameters
    ----------
    m_from : string
        String containing an email address
    
    Returns
    -------
        : string
        display name
    """
    delimiter = ' <'

    if delimiter in m_from:
        idx = m_from.find(delimiter)
        logger.info("There is Display Name")
        return m_from[:idx]
    else:
        logger.info("There is no Display Name")
        return "no_display_name"

Lambda ( Slack 通知 )

通知用の Lambda は、特定のディレクトリ名 ( メールの From ) 配下にオブジェクトが作成されたことを EventBridge が検知しキックされる想定です。
つきましては、検知させる From 毎に EventBridge と Lambda の組を用意する可能性があったため、モジュール読み込みの際に複数のリソースの組を定義できるように工夫し、且つ、本リポジトリで管理していない Lambda も参照できる形にしています。

※ notifier_groups にリストとして EventBridge と Lambda の組を渡します。

・notifier.tf.example

module "notifier" {
  source = "./modules/notifier"

  project     = var.project
  environment = var.environment

  notifier_groups = [
    {
      // Unique identifier
      name = "xxxx"
      // Triggering buckets
      bucket_name = "xxxx"
      // Triggering first renovation folder
      prefix = "xxxx"
      // Lambda Arn
      lambda_arn = "xxxx"
      // Lambda Function name
      lambda_function_name = "xxxx"
    },
    {
      name                 = "xxxx"
      bucket_name          = "xxxx"
      prefix               = "xxxx"
      lambda_arn           = "xxxx"
      lambda_function_name = "xxxx"
    },
    {
      name                 = "xxxx"
      bucket_name          = "xxxx"
      prefix               = "xxxx"
      lambda_arn           = "xxxx"
      lambda_function_name = "xxxx"
    }
  ]
}

Slack 通知用の Lambda は EventBridge から渡されたオブジェクトを読み込み、適宜整形して Slack の WebhookURL にリクエストを発行する流れとなります。

From の種類ごとに、異なった処理を行う Lambda 関数を用意する想定なので、1 サンプルとして基本的な Lambda 関数をリポジトリに配置しています。

・modules/notifier/example/slack/lambda/source/slack.py

import boto3
import json
import logging
import os
import re

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):

    logger.info("Event: " + str(event))

    bucket_name = str(event['detail']['bucket']['name'])
    logger.info("Bucket: " + str(bucket_name))
    object_key = event['detail']['object']['key']
    object_list = event['detail']['object']['key'].split("/")

    for i in object_list:
        logger.info("Object: " + str(i))

    m_from = object_list[0]
    subject = object_list[1]
    date = object_list[2]
    content = read_s3_object(bucket_name, object_key)

    res = notify2slack(m_from, subject, date, content, object_key)

    logger.info("Response: " + str(res))


def read_s3_object(bucket_name, object_key):
    """
    Read files From S3.

    Parameters
    ----------
    bucket_name : string
        Bucket Name
    object_key : string
        File Name ( Path )

    Returns
    -------
    body : dict
        API Return Values
    """

    s3 = boto3.client("s3")

    try:
        res = s3.get_object(
            Bucket=bucket_name,
            Key=object_key
            )
        logger.info("Success: %s", res)
        body = res['Body'].read().decode('utf-8')
    except Exception as e:
        logger.error("Error: %s", e)
    return body

def notify2slack(m_from, subject, date, content, object_key):
    """
    Notify messages to slack.

    Parameters
    ----------
    m_from : string
        Mail From
    subject : string
        Mail Subject
    date : string
        Mail Date
    content : string
        Mail Content
    object_key : string
        File Name ( Path )
        
    Returns
    -------
    res : dict
        API Return Values
    """

    slack_message = {
        "channel": os.environ['channelName'],
        "icon_emoji": ":rotating_light:",
        "attachments": [
            {
                "color": "#FF0000",
                "title": "Email has been received.",
                "text": "<!here> \n *Content* \n ```%s``` \n" % (content),
                "fields": [
                    {
                        "title": "Date",
                        "value": date,
                        "short": True
                    },
                    {
                        "title": "Subject",
                        "value": subject,
                        "short": True
                    },
                    {
                        "title": "From",
                        "value": m_from,
                        "short": True
                    },
                    {
                        "title": "object_key",
                        "value": object_key,
                        "short": True
                    }
                ]

            }
        ]
    }

    req = Request(decrypt_hookurl(os.environ['HookUrl']), json.dumps(slack_message).encode('utf-8'))

    try:
        res = urlopen(req)
        res.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)
    return res


def decrypt_hookurl(hookurl):
    """
    Notify messages to slack.

    Parameters
    ----------
    hookurl : string
        WebhookURL that may be encrypted
        
    Returns
    -------
    hookurl : string
        WebhookURL

    decrypted_hookurl : string
        Decrypted WebhookURL
    """

    if  "hooks.slack.com" in hookurl:
        logger.info("HookURL is not Encrypted")
        return hookurl
    else:
        logger.info("HookURL is Encrypted")
        decrypted_hookurl = boto3.client('kms').decrypt(
            CiphertextBlob=b64decode(hookurl),
            EncryptionContext={'LambdaFunctionName': os.environ['AWS_LAMBDA_FUNCTION_NAME']}
        )['Plaintext'].decode('utf-8')
        return decrypted_hookurl

最後に

このブログを始めて、もう少しで 5 年が経過しそうです。
※ IT 業界で働きだしたのが 3 月なので、今月で丁度 5 年になります。
あくまでも自分の備忘録として存在しているサイトですので、稚拙な内容に関しては何卒ご容赦頂けると幸いでございます…。

引き続きどうぞよろしくお願いいたします。(•ᵕᴗᵕ•)

【負荷試験】ブラウザ上で JMeter を操作できるツールを作ってみた話【Flanker】

こんにちは。
突然ですが、皆様は負荷試験を実施したことはございますでしょうか?
私は仕事柄、時折、負荷試験案件に携わることがございまして。
あくまで私のケースですが、おおよそ下記のようなフェーズを辿ることが多いように感じています。

1. 試験設計 ( 方針設計 / 目標決め / シナリオ作成 etc )
2. JMeter 環境構築
3. 負荷試験環境調整 ( 申請 / モニタリング環境 etc )
4. JMeter 実行 ( シナリオ調整 / JMeter 調整 含む )
5. 調査 / 解析
6. 「4」と「5」のエンドレス…

「1」に関してはお客様と詳細なやり取りを行い、いわば要件定義に近い折衝を行う必要がございまして、シナリオ作成に関しては API 仕様書を元に、JMeter から発行するリクエスト内容を定義していかなくてはなりません。

「2」や「3」に関しては、 Ansible 等のツールで省力化に取り組んでいるのですが、その他のフェーズでは依然として雑多なタスクが重なり、大変ストレスがマッハでございました。

経緯

さて、つきましては、雑多なタスク減を目的に、この度「4」の JMeter 操作周りを WEB ベースで完結できるツールを作ってみようと思った次第でございます。
始めに必要な機能を洗い出し、実装する機能を下記 5 ポイントに絞りました。

・シナリオ ( .jmx ) のアップロード
・シナリオ内 Thread group コンポーネントの調整 ( 負荷強度の調整等 etc )
・クラスタリングした JMeter に紐づく Worker ノードの操作
・JMeter の実行、および非同期での進捗確認
・実行結果生成

前提として JMeter を扱うのですから、 最終的には JMeter の WEB クライアントを作る形となります。

既に JMeter 環境のデプロイ用途で Ansible を使っているのもあり、ツールの実行環境含めて Playbook に内包しました。

できたもの

・Flanker

https://github.com/snkk1210/flanker

ツール、および JMeter 環境のデプロイは、下記 Ansible の Playbook を流していただければ完了いたします。

※ 詳細は README を参照ください。

https://github.com/snkk1210/jmeter-MS

実際にツールを使って案件を進めてみたのですが、かなり仕事が楽になったように思います。
※ 作った人間ばいあす

これからも、これ系のツールを作って業務の効率化を進めていきたいと思います。
わぁい。

【AWS】ECR 最新イメージの脆弱性スキャン結果を CSV ライクに出力する【Bash】

こんにちは。お久しぶりでございます。
昨年の秋に受験した 情報処理安全確保支援士試験 に合格してたようで大変浮足立っております。
ただ、支援士資格に関してはコストが高額なのもあり、未登録のままにしておこうかと考えている今日この頃でございます。

さて、話は変わりますが Inspector V2 で ECR のイメージを継続的にスキャンしている環境がございまして。
複数の ECR リポジトリに存在する最新イメージにて検知された脆弱性スキャン結果のみを抽出する必要がございまして。
ちょっとしたスクリプトを組む必要が出てきまして。
わりかし今後も似たようなスクリプトを組む気がしているので、自分用のイディオムとして残しておきます。

スクリプト

ロギング ( log.sh ) / credentials ( env.sh ) の処理は別ファイルから読み込む前提とします。
また、対象とする ECR リポジトリに関しても、リスト ( list.sh ) を作成し、外部ファイルから読み込む形とします。
※ list.sh については、引数を渡して ECR リポジトリを出力する処理である前提です。

#!/usr/bin/bash

PWD=`dirname ${0}`

source ${PWD}/../etc/env.sh
. ${PWD}/../lib/log.sh

if [ $# != 1 ]; then
    echo "Error: invalid argument."
    emitLog "Error: invalid argument."
    exit 1
fi

if !(type "aws" > /dev/null 2>&1); then
    echo "Error: aws command is not found."
    emitLog "Error: aws command is not found."
    exit 1
fi

## GET LATEST IMAGE TAG

container_array=($(/bin/bash ${PWD}/../etc/list.sh ${1}))

container_index=0
for i in ${container_array[@]}
do
     tag_array[${container_index}]=`aws ecr describe-images \
          --region ap-northeast-1 \
          --repository-name ${i} \
          --query 'sort_by(imageDetails,& imagePushedAt)[-1]' --output json \
          | jq -r '.imageTags[0]'`

     let container_index++
done

## SHOW SCAN FINDING

tag_index=0
for i in ${tag_array[@]}
do
     echo "/=================================================/"
     aws ecr describe-image-scan-findings \
          --region ap-northeast-1 \
          --repository-name ${container_array[$tag_index]} \
          --image-id imageTag=${i} \
          | jq ".repositoryName, .imageId.imageTag"

     element_num=`aws ecr describe-image-scan-findings \
          --region ap-northeast-1 \
          --repository-name ${container_array[$tag_index]} \
          --image-id imageTag=${i} \
          | jq '.imageScanFindings.enhancedFindings | length'`
     
     element_num=$(($element_num - 1))
     for j in `seq 0 ${element_num}`
     do
          echo "-------------------------------------------------"
          aws ecr describe-image-scan-findings \
               --region ap-northeast-1 \
               --repository-name ${container_array[$tag_index]} \
               --image-id imageTag=${i} \
               | jq -r "[.imageScanFindings.enhancedFindings[$j].packageVulnerabilityDetails.vulnerablePackages[0].name, .imageScanFindings.enhancedFindings[$j].packageVulnerabilityDetails.vulnerabilityId, .imageScanFindings.enhancedFindings[$j].severity, .imageScanFindings.enhancedFindings[$j].score] | @csv"
     done

     let tag_index++     
done

ネストしまくりで可読性がアレですね。
もっと上手な書き方あれば教えてください。

【JMeter】BeanShell Pre/PostProcessor での JSON 操作例【シナリオ作成】

こんにちは。
負荷試験や性能テストでよく使われる JMeter ですが、コンポーネントの 1つに、BeanShell PreProcessor / PostProcessor という機能が存在します。
こちらは JMeter からのリクエストのレスポンス内容をパースして値を取得したり、発行するリクエストのパラメータを動的に変化させたりと、割となんでもできてしまう便利なコンポーネントになっています。
※ 処理自体は Java でスクリプトを記述していく必要があり、少し癖があるかもですが、それほど難易度が高いわけでもないと思います。

今回は実際にリクエストを投げる API を用意して、シナリオを作成しつつ良く使うパターンを備忘録として残しておこうと思います。
用意する API は Zabbix 辺りが都合よさそうだったので、こちらに対しての負荷試験シナリオ、という前提で作成していきます。
※ Zabbix API の仕様に関しては こちら を参照ください。

JMeter (始めに)

使用する JMeter は現時点で最新版である JMeter 5.5 を使用します。
また、JSON の操作が必要になってくるため、JSONObject のライブラリファイルを JMeter の lib/ ディレクトリ配下に配置しておきます。
※ ファイルは こちら から ZIP 形式でダウンロードできます。

また、シナリオ作成前に JMeter の下記辺りのコンポーネントを用意しておくのが良いかもしれないです。

・View Results Tree : シナリオデバッグ用途
・HTTP Request Defaults : リクエスト共通の設定を定義
※ 全ての Zabbix API は /zabbix/api_jsonrpc.php に対して POST メソッドを用いて API を叩きます。
・HTTP Header Manager : リクエスト共通のリクエストヘッダを定義
※ 全ての Zabbix API へのリクエストには下記のリクエストヘッダが必要です。

Content-Type: application/json-rpc

Zabbix API へは JSON 形式で POST リクエストを発行するため、作成する HTTP Request コンポーネントの Body Data には ${request} という変数を設定しておきます。
※ PreProcessor でリクエストの JSON データを生成します。

作成するシナリオは

user.login → hostgroup.get → host.create → host.get → host.delete → user.logout

の順に偏移する想定です。

シナリオ

シナリオ全体で扱う値用に PreProcessor を用意しておきます。
※ Zabbix ログインユーザとログ出力モード切替の定義用です。
今回はデフォルトの Zabbix 特権ユーザを使用します。

/** 
 * 各変数の初期化
 * 定数値等を設定
 * 
 * initialize Script
 * Bean Shell PreProcessor
 */
// Execute mode
 vars.put("MODE", "debug");
//vars.put("MODE", "release");

/** 
 * ログイン ユーザ
 */
// login User
vars.put("username", "Admin");

// Login Password
vars.put("password", "zabbix");

1. user.login

始めに Zabbix の認証キーを取得します。

・PreProcessor
Zabbix API の仕様に沿った通りに JSON のリクエストを発行します。
ユーザ情報は先の PreProcessor にて設定した値を呼び出しています。

import org.json.JSONObject;
import org.json.JSONArray;

// param JSONObject 生成
JSONObject paramObject = new JSONObject();

paramObject.put("user", vars.get("username"));
paramObject.put("password", vars.get("password"));

JSONArray params = new JSONArray();
params.put(paramObject);

JSONObject param = params.getJSONObject(0);

// POST Body (JSON) 生成  
JSONObject objRequest = new JSONObject();

objRequest.put("method", "user.login");
objRequest.put("id", 1);
objRequest.put("params", param);
objRequest.put("jsonrpc", "2.0");

if (vars.get("MODE") == "debug") {
    log.info(objRequest.toString());
}

// データをセット
vars.put("request", objRequest.toString());

・PostProcessor
レスポンスから認証キーを取得します。
※ 認証キーは auth_key に格納しておき、以降のリクエストで利用します。

import org.json.JSONObject;

if (vars.get("MODE") == "debug") { log.info("-- Requested Sample(Pre): " + prev.getSampleLabel()); }

// レスポンスを取得
JSONObject json = new JSONObject(prev.getResponseDataAsString());

// レスポンスから 認証キー を取得
var result = json.getString("result");

if (vars.get("MODE") == "debug") { 
	log.info(result.toString());
}

// 変数にセット
vars.put("auth_key", result.toString());

2. hostgroup.get

ホストグループ Discovered hosts の詳細情報をリクエストします。

・PreProcessor
深い入れ子の JSON を生成する必要があるため、少しだけ工夫します。
また、先のリクエストにて取得した auth_key を呼び出して使用します。

import org.json.JSONObject;
import org.json.JSONArray;

// filter JSONObject 生成
JSONObject filterObject = new JSONObject();

filterObject.put("name", "Discovered hosts");

JSONArray filters = new JSONArray();
filters.put(filterObject);

JSONObject filter = filters.getJSONObject(0);

// param JSONObject 生成
JSONObject paramObject = new JSONObject();

paramObject.put("filter", filter);

JSONArray params = new JSONArray();
params.put(paramObject);

JSONObject param = params.getJSONObject(0);

// POST Body (JSON) 生成 
JSONObject objRequest = new JSONObject();

objRequest.put("auth", vars.get("auth_key"));
objRequest.put("method", "hostgroup.get");
objRequest.put("id", 1);
objRequest.put("params", param);
objRequest.put("jsonrpc", "2.0");

if (vars.get("MODE") == "debug") {
    log.info(objRequest.toString());
}

// データのセット
vars.put("request", objRequest.toString());

3. host.create

Zabbix のホストを作成します。

・PreProcessor
作成するホストの名称は複数スレッドでリクエストを流した際の重複を避けるため、Random クラスの nextInt() を用いて、ランダムな値を付与します。
IP アドレスとポートは重複しても問題ないため、固定値に設定しています。

import org.json.JSONObject;
import org.json.JSONArray;
import java.util.Random;

// 0 ~ 999999999 の数値を取得
Random rand = new Random();
long user_id = rand.nextInt(999999999);

// groups JSONObject 生成
JSONObject groupsObject = new JSONObject();

groupsObject.put("groupid", vars.get("groupid"));

JSONArray groups = new JSONArray();
groups.put(groupsObject);

JSONObject group = groups.getJSONObject(0);

// interfaces JSONObject 生成
JSONObject interfacesObject = new JSONObject();

interfacesObject.put("type", 1);
interfacesObject.put("main", 1);
interfacesObject.put("useip", 1);
interfacesObject.put("ip", "192.168.10.100");
interfacesObject.put("dns", "");
interfacesObject.put("port", "10050");

JSONArray interfaces = new JSONArray();
interfaces.put(interfacesObject);

JSONObject interfac = interfaces.getJSONObject(0);


// param JSONObject 生成
JSONObject paramObject = new JSONObject();

paramObject.put("host", "Test Server" + user_id.toString());
paramObject.put("groups", group);
paramObject.put("interfaces", interfac);

JSONArray params = new JSONArray();
params.put(paramObject);

JSONObject param = params.getJSONObject(0);

// POST Body (JSON) 生成 
JSONObject objRequest = new JSONObject();

objRequest.put("auth", vars.get("auth_key"));
objRequest.put("method", "host.create");
objRequest.put("id", 1);
objRequest.put("params", param);
objRequest.put("jsonrpc", "2.0");

if (vars.get("MODE") == "debug") {
    log.info(objRequest.toString());
}

// データのセット
vars.put("request", objRequest.toString());

4. host.get

Zabbix のホスト一覧を取得し、ランダムにホスト ID を取得します。

・PreProcessor

import org.json.JSONObject;
import org.json.JSONArray;

// param JSONObject 生成
JSONObject paramObject = new JSONObject();

paramObject.put("output", "extend");

JSONArray params = new JSONArray();
params.put(paramObject);

JSONObject param = params.getJSONObject(0);

// POST Body (JSON) 生成 
JSONObject objRequest = new JSONObject();

objRequest.put("auth", vars.get("auth_key"));
objRequest.put("method", "host.get");
objRequest.put("id", 1);
objRequest.put("params", param);
objRequest.put("jsonrpc", "2.0");

if (vars.get("MODE") == "debug") {
    log.info(objRequest.toString());
}

// データのセット
vars.put("request", objRequest.toString());

・PostProcessor
こちらも Random クラスのメソッドを用いて、レスポンスからランダムな hostid を取得し変数に格納しておきます。
※ 後述のホストの削除にて利用します。

import org.json.JSONObject;
import java.util.Random;

if (vars.get("MODE") == "debug") { log.info("-- Requested Sample(Pre): " + prev.getSampleLabel()); }

// レスポンス取得
JSONObject json = new JSONObject(prev.getResponseDataAsString());

var result = json.getJSONArray("result");

// JsonObject の要素数を数える
int length = result.length();

// ランダムな要素数を選定
Random rand = new Random();
int num = rand.nextInt(length);

// ランダムな要素を取得 
JSONObject jObj1 = result.getJSONObject(num);

var hostid = jObj1.getString("hostid");

if (vars.get("MODE") == "debug") { 
	log.info(hostid.toString());
}

// 変数セット
vars.put("hostid", hostid.toString());

5. host.delete

Zabbix のホストを 1つ削除します。

・PreProcessor
先ほど取得した hostid を利用してリクエストを発行します。

import org.json.JSONObject;
import org.json.JSONArray;

String[] hostidArr;
hostidArr = new String[1];
hostidArr[0] = vars.get("hostid");

// POST Body (JSON) 生成 
JSONObject objRequest = new JSONObject();

objRequest.put("auth", vars.get("auth_key"));
objRequest.put("method", "host.delete");
objRequest.put("id", 1);
objRequest.put("params", hostidArr);
objRequest.put("jsonrpc", "2.0");

if (vars.get("MODE") == "debug") {
    log.info(objRequest.toString());
}

// データのセット
vars.put("request", objRequest.toString());

6. user.logout

最後にログアウトして認証キーを無効化しておきます。

・PreProcessor

import org.json.JSONObject;
import org.json.JSONArray;

// POST Body (JSON) 生成 
JSONObject objRequest = new JSONObject();

objRequest.put("auth", vars.get("auth_key"));
objRequest.put("method", "user.logout");
objRequest.put("id", 4);
objRequest.put("params", null);
objRequest.put("jsonrpc", "2.0");

if (vars.get("MODE") == "debug") {
    log.info(objRequest.toString());
}

// データのセット
vars.put("request", objRequest.toString());

終わりに

恐らく BeanShell PreProcessor / PostProcessor を使えば、書き方次第で、おおよその要件は叶えられるんじゃないでしょうか?
※ 今回のシナリオファイルのサンプルは こちら に配置しております。

話は変わりますが、こちら に JMeter クラスタ環境デプロイ用の Ansible Playbook を配置しておりますのでご利用頂ければ嬉しいです。

【Python】Backlog API を叩いて課題一覧を Excel に出力してみる【openpyxl】

こんにちは。
とある事情で月次で発生した障害情報を Excel に纏めて提出する必要が出てきたので、スクリプトを組んでみようと思います。
発生した障害は Backlog の Webhook 機能を使って課題として登録されているので、Backlog API を叩いて月の障害課題を収集します。
Python には openpyxl という Excel 操作用のライブラリが用意されているようなので、甘んじて使ってみます。
※ Python よく分からない民なので、割とむちゃくちゃしてるかもです。

ディレクトリ構成

テンプレートとなる Excel ファイルを templates ディレクトリ配下に配置しておき、API を叩いて取得した課題一覧をマージして output に出力する構成にしてみます。

.
├── main.py
├── output
└── templates
    └── template.xlsx

スクリプト

念のため API Key、スペース ID、プロジェクト ID 等の秘匿情報周りは環境変数から読み込めるようにしておきます。
また、課題の検索期間、検索キーワードはコマンドライン引数から指定できるように調整します。

#!/usr/bin/python3
# coding: utf-8

import json
import os
import urllib.request
import urllib.parse
import datetime
import sys
import openpyxl
import argparse

parser = argparse.ArgumentParser(
    usage='Specify the collection period in -p and the search word in -w as arguments',
    description='Output a list of issues to Excel by hitting the BackLog API.'
    )
parser.add_argument('-p', '--period', required=True, help='Specify the time period for collecting issues (1: this month to today or 2: last month)')
parser.add_argument('-w', '--word', required=True, help='Specify search words')
args = parser.parse_args()

if __name__ == '__main__':
    if args.period == "1":
        CREATED_SINCE = datetime.datetime.now().strftime('%Y' + "-" + '%m') + '-01'
        CREATED_UNTIL = datetime.datetime.now().strftime('%Y' + "-" + '%m' + "-" + '%d')
    elif args.period == "2":
        CREATED_SINCE = (datetime.datetime.today() - datetime.timedelta(datetime.datetime.today().day)).strftime('%Y-%m') + '-01'
        CREATED_UNTIL = (datetime.datetime.today() - datetime.timedelta(datetime.datetime.today().day)).strftime('%Y-%m-%d')
    else:
        print('Invalid argument.')
        sys.exit(1)

BACKLOG_API_KEY = os.environ['BACKLOG_API_KEY']
SPACE_ID = os.environ['SPACE_ID']
PROJECT_ID = os.environ['PROJECT_ID']
KEYWORD = urllib.parse.quote(args.word)
#CREATED_SINCE = '2022-xx-01'
#CREATED_UNTIL = '2022-xx-31'

URL = f'https://{SPACE_ID}.backlog.jp/api/v2/issues?apiKey={BACKLOG_API_KEY}&projectId[]={PROJECT_ID}&createdSince={CREATED_SINCE}&createdUntil={CREATED_UNTIL}&count=100&keyword={KEYWORD}'

def utc_to_jst(timestamp_utc):
    """
    @string
    UTC → JST に変換する
    """
    datetime_utc = datetime.datetime.strptime(timestamp_utc, "%Y-%m-%dT%H:%M:%SZ")
    datetime_jst = datetime_utc + datetime.timedelta(hours=9)
    timestamp_jst = datetime.datetime.strftime(datetime_jst, "%Y-%m-%d %H:%M:%S")
    return timestamp_jst
    
def get_alarm_list(url):
    """
    @string
    BackLog の課題を取得しパースしてから配列に突っ込む
    """
    request = urllib.request.Request(url)
    with urllib.request.urlopen(request) as response:
        data = json.loads(response.read().decode("utf-8"))

        lists = []
        for i in range(len(data)):
            lists.append(utc_to_jst(data[i]['created']) + "," + data[i]['summary'])
    return lists

"""
# NOTE: BackLog API 叩く
"""
res = get_alarm_list(URL)

"""
# NOTE: テンプレート( Excel )読み込み
"""
wb = openpyxl.load_workbook("templates/template.xlsx")
cover_ws = wb.worksheets[0]
list_ws = wb.worksheets[1]

"""
# NOTE: 表紙に資料作成日を追記
"""
cover_ws.cell(column=10, row=13, value=datetime.datetime.now().strftime('%Y' + "-" + '%m' + "-" + '%d'))

"""
# NOTE: 全課題を Excel シートに突っ込む
"""
for i in range(len(res)):
    list_ws.cell(column=3, row=(i + 3), value=res[i].split(',')[0])
    list_ws.cell(column=4, row=(i + 3), value=res[i].split(',')[1])
    print(res[i])

"""
# NOTE: Excel 保存
"""
wb.save("output/MonthlyAlarm_" + datetime.datetime.now().strftime('%Y%m%d%H%M%S') + ".xlsx")

openpyxl 以外はビルトインのモジュールを使ってみました。
※ いつも通りエラーハンドリングは 無視 省力です。自分用の備忘録ですしおすし。

【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 の管理画面から詳細な情報を確認することも可能です。

【GCP】Booting from Hard Disk 0… から復旧させた話【P2V】

こんにちは。
先日に投稿した下記方法で GCP に P2V (RHEL6) を試みました。
…が、上手くVM が起動しなかったため、頑張って復旧させた際の備忘録です。

【V2V】dd コマンドでデータを丸ごと吸い出して GCP にサーバを移設してみる


シリアルコンソールから確認すると、出力が Booting from Hard Disk 0… にて止まっている模様。
仕方がないので、レスキュー用の VM を起動し、正常にブートするディスク (復旧ディスク) に対して、問題の (起動しない) ディスクデータを移行し VM の起動を目指します。

1. 復旧ディスクを用意

GCP では既に RHEL6 / CentOS6 のイメージを提供していません。
ついては、AWS にて CentOS6 のイメージを起動し、dd コマンドでディスクのイメージを取得してから GCP にて VM を作成します。
※ 詳細については こちら を参照ください。

※ 必要に応じてディスクを拡張しておきます。

sed -i -e "s/^mirrorlist=http:\/\/mirrorlist.centos.org/#mirrorlist=http:\/\/mirrorlist.centos.org/g" /etc/yum.repos.d/CentOS-Base.repo
sed -i -e "s/^#baseurl=http:\/\/mirror.centos.org/baseurl=http:\/\/vault.centos.org/g" /etc/yum.repos.d/CentOS-Base.repo
yum install epel-release
yum install cloud-utils-growpart
growpart /dev/sda 1
reboot
resize2fs /dev/sda1

2. データ移行

レスキュー用の VM を起動し、「1」にて作成したディスクと、問題の (起動しない) ディスクをアタッチし、それぞれ適当なディレクトリにマウントします。

※ /mnt/source01 → 問題の (起動しない) ディスク
※ /mnt/dest01 → 復旧ディスク

データ移行には rsync を使います。
※ 予め同期させないデータのリストファイルを作成してから実行します。

vim /var/tmp/exclude_list.txt 
=============================
# necessary-file exclude
- /boot/*
- /dev/*
- /sys/*
- /proc/*
- /etc/fstab
- /etc/passwd
- /etc/shadow
- /run/*

nohup rsync -av --exclude-from=/var/tmp/exclude_list.txt /mnt/source01/* /mnt/dest01/ &

※ OS ユーザはエントリの重複が発生しないように /etc/passwd 、/etc/shadow に手動で追記します。

データ同期が完了したら、ディスクをアンマウントし VM を起動します。

3. ネットワーク周り調整

VM 起動後はシリアルコンソールから接続します。
※ ログインプロンプトが表示されたら成功です。
P2V だとネットワーク周りの設定が競合して疎通ができない状態になっていると思うので適宜調整します。

※ たいていは network-scripts 配下のファイルを調整し、75-persistent-net-generator.rules を退避(削除)すれば良いかと。

調整後は VM を再起動して下さい。

vim /etc/sysconfig/network-scripts/ifcfg-eth0
=============================================
IPV6INIT="no"
DHCP_HOSTNAME="localhost"
BOOTPROTO="dhcp"
DEVICE="eth0"
ONBOOT="yes"
MTU=1460
PERSISTENT_DHCLIENT="y"
IPV6INIT=yes

mv /etc/udev/rules.d/75-persistent-net-generator.rules /var/tmp/

※ GATEWAY は VPC のゲートウェイですね。
vim /etc/sysconfig/network
==========================
NETWORKING=yes
HOSTNAME=hogehoge
NETWORKING_IPV6=no
NOZEROCONF=YES
GATEWAY=hogehoge

4. 終わりに

VM が起動しない時は手詰まりで泣きそうになりました…。
一か八かでデータの同期を試みたところ正常に起動したようで何よりです。
この案件、色々と勉強になりました…。