Lambda 関数の環境変数の設定に AWS Secrets Manager を使ってみた話

2023.04.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、高崎@アノテーション です。

(2023.05.22 追記)
本記事はテンプレート上で平文での実装無しに環境変数へ埋め込む主旨で記載いたしましたが、環境変数に平文が見えることが問題になる場合も鑑み、Lambda から直接取得する方法も公開する予定です。

はじめに

AWS Lambda 関数で用意されている環境変数は このドキュメント に記載のある「ユーザ責任による適切な保護」がなされていれば機密性が担保されていますので、例えばデータベースへの接続といったセンシティブな情報を格納しておいて、Lambda 関数で接続実行するときにこの環境変数を使用して接続する、といったことを行います。

ところが AWS CDK にてテンプレートを定義して環境変数へデプロイする、というシステムを構築する際、ソース上にセンシティブな情報を平文で定義すると漏洩等のリスクが存在しますので、今回は AWS Secrets Manager を用いてこういった情報を保持し、Lambda 関数の登録時に展開する方法を実践したいと思います。

課題例と対策例

課題〜ソース上にセンシティブな情報を定義している〜

手前味噌ですが 前回のブログ にて LINE ボットアプリを作成しましたが、下記のテンプレート上に、チャネルアクセストークンとチャネルシークレットという LINE においてはセンシティブな情報2つを平文で実装していました(ハイライトの箇所)。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';

export class LineBotTestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here

    // example resource
    // Lambda 関数の作成
    const lambdaParrotingBot = new lambda.Function(this, 'LineParrotingBot', {
        runtime: lambda.Runtime.NODEJS_18_X,
        handler: 'index.handler',
        code: lambda.Code.fromAsset('src/lambda'),
        environment: {
            ACCESS_TOKEN: "You must change here to your LINE Developer's access token code.",
            CHANNEL_SECRET: "You must change here to your LINE Developer's channel secret code.",
        }
    });
    // API Gateway の作成
    const api = new apigateway.RestApi(this, 'LineParrotingApi', {
        restApiName: 'LineParrotingApi'
    });
    // proxy ありで API Gateway に渡すインテグレーションを作成
    const lambdaInteg = new apigateway.LambdaIntegration(
        lambdaParrotingBot, { proxy: true });
    // API Gateway の POST イベントと Lambda との紐付け
    api.root.addMethod('POST', lambdaInteg);
  }
}

ソース上にこういったセンシティブな情報を残したままですと、ソースが誤って悪意ある第三者へ漏洩してしまった場合、ボットから 予期せぬ不適切なメッセージが登録者全員に発信される といったリスクがあります。

対策方針〜Secrets Manager を使う〜

そこで、

  1. チャネルアクセストークンとチャネルシークレットを Secrets Manager に登録する。
  2. CDK のテンプレートにて Lambda を登録時に取得する。

…を行い、ソース上に記載しないよう修正します。

修正作業

Secrets Manager への登録

まずは、Secrets Manager にセンシティブなデータを登録します。
「新しいシークレットを保存する」を選びます。

ステップ1〜シークレットのタイプを選択〜

今回はチャネルアクセストークン、チャネルシークレットの2つになりますので、データベースに関係しない「その他のシークレットのタイプ」から2つのペア文字列を登録し、「次」を押します。

ステップ2〜シークレットを設定〜

シークレットの名前は必須ですので設定(今回は「LineAccessInformation」とします)して「次」へ。

ステップ3〜ローテーションを設定〜

今回は定期更新を行う Lambda 関数は用意していないのでそのまま「次」へ。

ステップ4〜レビュー〜

画面例は見切れていますが、スクロールして最下部にある「保存」ボタンを押して登録となります。

登録後の ARN 文字の最後の6文字(下記の下線が引いてある部分)を覚えておいてください(後で使います)。

実装の修正

実装の方針としては下記になります。

  1. aws-cdk-lib/aws-secretsmanager から Secret を import
  2. aws-cdk-lib から ScopeAws を import
  3. JSON 取得
  4. JSON から環境変数へセット

import する

1. と 2. をまとめますと、こんな書き方。

import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { ScopedAws } from 'aws-cdk-lib'

なお、スーパークラスに cdk.Stack クラスを設定しているクラスはリージョン、アカウントIDは this から取れますが、汎用性(取得処理を機能ごとに分割する場合等)を考え ScopedAws を使用することにします。

JSON 取得

取得は下記のような形で実装します(ハイライト行)。
※「XXXXXX」は登録時に覚えておいた6文字になります。

    // Secrets Manager から LINE のセンシティブな情報を取得
    const { region, accountId } = new ScopedAws(this);
    const secretJson = Secret.fromSecretAttributes(this, 'SecretStrings', {
        secretCompleteArn: `arn:aws:secretsmanager:${region}:${accountId}:secret:LineAccessInformation-XXXXXX`,
    });

JSON から環境変数へセット

取得した JSON からキーをそれぞれ設定して文字列を取ってきます(ハイライト行)。

    const lambdaParrotingBot = new lambda.Function(this, 'LineParrotingBot', {
          :
        environment: {
            ACCESS_TOKEN: `${secretJson.secretValueFromJson('ACCESS_TOKEN').toString()}`,
            CHANNEL_SECRET: `${secretJson.secretValueFromJson('CHANNEL_SECRET').toString()}`
        }
    });

実装したソースは下記になります。修正箇所をハイライトしております。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { ScopedAws } from 'aws-cdk-lib';

export class LineBotTestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here

    // Secrets Manager から LINE のセンシティブな情報を取得
    const { region, accountId } = new ScopedAws(this);
    const secretJson = Secret.fromSecretAttributes(this, 'SecretStrings', {
        secretCompleteArn: `arn:aws:secretsmanager:${region}:${accountId}:secret:LineAccessInformation-XXXXXX`,
    });
    // example resource
    // Lambda 関数の作成
    const lambdaParrotingBot = new lambda.Function(this, 'LineParrotingBot', {
        runtime: lambda.Runtime.NODEJS_18_X,
        handler: 'index.handler',
        code: lambda.Code.fromAsset('src/lambda'),
        environment: {
            ACCESS_TOKEN: `${secretJson.secretValueFromJson('ACCESS_TOKEN').toString()}`,
            CHANNEL_SECRET: `${secretJson.secretValueFromJson('CHANNEL_SECRET').toString()}`
        }
    });
    // API Gateway の作成
    const api = new apigateway.RestApi(this, 'LineParrotingApi', {
        restApiName: 'LineParrotingApi'
    });
    // proxy ありで API Gateway に渡すインテグレーションを作成
    const lambdaInteg = new apigateway.LambdaIntegration(
        lambdaParrotingBot, { proxy: true });
    // API Gateway の POST イベントと Lambda との紐付け
    api.root.addMethod('POST', lambdaInteg);
  }
}

実装において参考にした文献:

動かしてみる

ビルドとデプロイは前回と同様、下記になります。

$ npm run build
$ cdk deploy
(中略)
Error: Resolution error: Resolution error: Resolution error: Resolution error: Synthing a secret value to Resources/${Token[TestStack.LineParrotingBot.Resource.LogicalID.605]}/Properties/environment/variables/ACCESS_TOKEN. Using a SecretValue here risks exposing your secret. Only pass SecretValues to constructs that accept a SecretValue property, or call AWS Secrets Manager directly in your runtime code. Call 'secretValue.unsafeUnwrap()' if you understand and accept the risks..
(中略)
Subprocess exited with error 1

…おっと、エラーが出ましたね。

どうやら SecretValue クラスは自身を受け入れるクラスではない限り取り出すとエラーになるようです(当たり前といえば当たり前ですね)。

unsafeUnwrap 関数 で取り出せるようですが、今回は開発検証の確認のような短期的な使用目的の前提で使ってみたいと思います。

        environment: {
            ACCESS_TOKEN: `${secretJson.secretValueFromJson('ACCESS_TOKEN').unsafeUnwrap()}`,
            CHANNEL_SECRET: `${secretJson.secretValueFromJson('CHANNEL_SECRET').unsafeUnwrap()}`
        }

ビルドしてデプロイ。

$ npm run build

> line_bot_test@0.1.0 build
> tsc

$ cdk deploy

✨  Synthesis time: 2.61s

LineBotTestStack: building assets...
    : 中略
✨  Total time: 31.95s

$

エラーなく出来ました。
では、Lambda 関数の AWS コンソールを見てみましょう。

なんとか、環境変数に登録されました。

おわりに

今回は Secrets Manager へ登録したデータを CDK にて Lambda の環境変数へデプロイする方法を実装してみました。

最終的なテンプレートのソースは下記となりました。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { ScopedAws } from 'aws-cdk-lib';

export class LineBotTestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here

    // Secrets Manager から LINE のセンシティブな情報を取得
    const { region, accountId } = new ScopedAws(this);
    const secretJson = Secret.fromSecretAttributes(this, 'SecretStrings', {
        secretCompleteArn: `arn:aws:secretsmanager:${region}:${accountId}:secret:LineAccessInformation-BhOZCx`,
    });
    // example resource
    // Lambda 関数の作成
    const lambdaParrotingBot = new lambda.Function(this, 'LineParrotingBot', {
        runtime: lambda.Runtime.NODEJS_18_X,
        handler: 'index.handler',
        code: lambda.Code.fromAsset('src/lambda'),
        environment: {
            ACCESS_TOKEN: `${secretJson.secretValueFromJson('ACCESS_TOKEN').unsafeUnwrap()}`,
            CHANNEL_SECRET: `${secretJson.secretValueFromJson('CHANNEL_SECRET').unsafeUnwrap()}`,
        }
    });
    // API Gateway の作成
    const api = new apigateway.RestApi(this, 'LineParrotingApi', {
        restApiName: 'LineParrotingApi'
    });
    // proxy ありで API Gateway に渡すインテグレーションを作成
    const lambdaInteg = new apigateway.LambdaIntegration(
        lambdaParrotingBot, { proxy: true });
    // API Gateway の POST イベントと Lambda との紐付け
    api.root.addMethod('POST', lambdaInteg);
  }
}

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。
「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。
現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。
少しでもご興味あれば、アノテーション株式会社WEBサイト をご覧ください。