Lambdaを安全にデプロイ・運用する方法

AWS

こんにちは、中山( @k1nakayama ) です。

皆さんは商用展開しているアプリケーションワークロードにおいて、CI/CDを構築してLambda等のリソースのデプロイをされていますでしょうか?
弊社で支援させていただいているプロジェクトの多くは、こうしたCI/CDが構築されており、GitLab等で変更内容のコードレビューの承認を受けてデプロイを進めていく形となっています。
こうした中で、開発者が手順に従って対応を進めている間は問題がないのですが、実際の本番運用がされてきて、できるだけ早急に修正したい問題などが発覚した際に、CI/CDを通さずマネジメントコンソール上から直接コードを修正して対応することなど、心当たりありませんか?
そのような対応をすることで、更なる問題が発生してしまったり、記録に残らない修正が入ってしまうことで、ガバナンスを保てなくなることが発生します。
最悪な場合、そのような運用が所々で行われている中で、内部犯によるDB内の情報漏洩などが行われるようなコード変更が見つかった場合、企業は大きな問題を抱えることになります。

こうした問題を発生させないようにするために、コード変更がCI/CDを通ったあとで変更されないようにしたり、外部に情報漏洩が起こしにくい形にしていくことが求められます。
今回は、そういった安全なデプロイや運用を行っていくための方法について解説いたします。

  • ガバナンスを効かせた安全なLambda運用を行いたい方
  • CI/CDを構築してLambdaのデプロイを行っている方
  • Lambdaを通した情報漏洩等に対する対策に興味がある方

Lambdaコード署名

まずはコードがデプロイ後に変更されていないことを検証する機能を有効化することで、マネジメントコンソール上からの変更を防ぐことをしたいと思います。
このために使う機能が「コード署名」の機能です。

Lambda Functionにコードをデプロイする前に、署名プロファイルによる証明書で署名を行い、デプロイ時署名内容を検証することで、コードが変更されていないことを担保する仕組みです。
このコード署名を行うためには、「AWS Signer」というサービスを使用して署名プロフィールを作成します。ここで作成したプロフィールの有効期限の範囲でコード署名は有効となります。

プロファイルを作成した後、Lambda Functionを作成する際に、その他の構成の設定で「コード署名を有効化」をチェックし、署名設定を行うことでコード署名が有効なLambdaとしてデプロイ出来ます。

コードの署名検証ポリシーをEnforceに設定しておかないと、コード署名に失敗してもログにWarningが出力されるだけになってしまうので、Enforceに設定することが望ましいです。

ちなみに、この設定を行う前にLambdaにアップロードするコードをZIP圧縮し、S3にアップロードした後に、Signerにて署名を行っておく必要があります。
今回は、最後にまとめてCDKを使用した手順を紹介したいと思いますので、割愛したいと思います。

DB情報のLambda経由での漏洩を防止する

個人情報等の機密情報を外部に漏洩することを防ぐ構造にしておくことで、万一コード内に情報を漏洩させてしまうコードが注入されても、漏洩自体を防ぐことができます。

この方法は意外と簡単で、VPC内にセキュアサブネット(インターネットと通信が行えないサブネット)を構築し、その中にLambdaやDB(DynamoDBなどのパブリックリソースの場合は、VPCエンドポイント経由で接続)を閉じ込めることで実現できます。

これにより、Lambdaのコードに外部APIなどへの情報を流出させるコードが紛れ込んだ場合でも、インターネットへのルートを持っていないため、接続できず漏洩を防ぐことが出来ます。

この方法を使用するためには、インターネット経由での接続をLambdaから行わなければならない処理が含まれていないことが前提となりますが、もしこのような処理が必要になる場合は、別途VPC外のLambdaとして作成を行い、必要な情報を外部への接続用Lambdaに渡すことで実現できます。(DB内の情報を外部のAPI等にリクエストする必要がある場合は、VPCエンドポイント経由で通信用Lambdaを呼び出すことで実現できます)

Secure Subnet内に配置したLambda

Signerの使用やIAMの使用をデプロイ用IAM Role以外には与えない

上記で説明したLambdaのコード署名やSecure Subnet内に配置させることなどをした場合でも、LambdaやEC2などのコンピューティング機能を持つリソースを新たにCI/CDを通さずにデプロイ出来てしまったら片手落ちとなってしまいます。
上記の対策と共に、SignerやIAM Role等の作成権限、サブネット等の作成権限などを開発者に与えないようしておく必要があります。

IAM Identity Centerで開発者がログインをしている場合、下記のようなポリシーを定義しておくことで要件を満たしやすくなります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowAllExceptDenied",
            "Effect": "Allow",
            "Action": "*",
            "Resource": "*"
        },
        {
            "Sid": "DenyIAMWriteActions",
            "Effect": "Deny",
            "Action": [
                "iam:Create*",
                "iam:Delete*",
                "iam:Put*",
                "iam:Update*",
                "iam:Attach*",
                "iam:Detach*"
            ],
            "Resource": "*"
        },
        {
            "Sid": "DenySpecificServices",
            "Effect": "Deny",
            "Action": [
                "signer:*",
                "ec2:RunInstances",
                "ec2:StartInstances",
                "ecs:CreateTask*",
                "ecs:RunTask",
                "ecs:StartTask"
            ],
            "Resource": "*"
        }
    ]
}

上記のようなポリシーを作成したうえで(上記を仮にDeveloperPermissionBoundaryとして作成)、許可セットは、AdministratorAccessを許可し、許可の境界としてDeveloperPermissionBoundaryを設定することで、上記で説明したようなアクションが実行できないユーザーとして運用できます。

CDKでデプロイ

CI/CDでデプロイしていくことを想定し、今回はCDKでLambdaをデプロイする方法を紹介します。

早速全体的なコードです。

from aws_cdk import (
    Stack,
    aws_ec2 as ec2,
    aws_dynamodb as dynamodb,
    aws_lambda as _lambda,
    aws_iam as iam,
    aws_signer as signer,
    RemovalPolicy,
    Duration,
)
from constructs import Construct


class SecureLambdaStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # コード署名の設定
        # 署名プロファイルの作成
        signing_profile = signer.SigningProfile(
            self, "SigningProfile",
            platform=signer.Platform.AWS_LAMBDA_SHA384_ECDSA,
            signing_profile_name="SecureLambdaSigningProfile"
        )

        # 署名プロファイルからの署名設定
        code_signing_config = _lambda.CodeSigningConfig(
            self, "CodeSigningConfig",
            signing_profiles=[signing_profile],
            untrusted_artifact_on_deployment=_lambda.UntrustedArtifactOnDeployment.ENFORCE
        )

        # VPCの作成
        vpc = ec2.Vpc(
            self, "SecureVPC",
            max_azs=2,
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    name="Secure",
                    subnet_type=ec2.SubnetType.PRIVATE_ISOLATED,
                    cidr_mask=24
                )
            ]
        )

        # Secure Subnetのサブネットリストを取得
        secure_subnet_ids = [
            subnet.subnet_id for subnet in vpc.isolated_subnets]

        # DynamoDB Gatewayエンドポイントの作成
        vpc.add_gateway_endpoint(
            "DynamoDBEndpoint",
            service=ec2.GatewayVpcEndpointAwsService.DYNAMODB,
            subnets=[ec2.SubnetSelection(
                subnet_type=ec2.SubnetType.PRIVATE_ISOLATED)]
        )

        # DynamoDBテーブルの作成
        user_table = dynamodb.Table(
            self, "UserTable",
            partition_key=dynamodb.Attribute(
                name="user_id",
                type=dynamodb.AttributeType.STRING
            ),
            removal_policy=RemovalPolicy.DESTROY,
            billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST
        )

        # Lambda関数のIAMロールの作成
        lambda_role = iam.Role(
            self, "LambdaRole",
            assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
        )

        # VPC関連の権限を付与
        lambda_role.add_to_policy(
            iam.PolicyStatement(
                effect=iam.Effect.ALLOW,
                actions=[
                    "ec2:CreateNetworkInterface",
                    "ec2:DescribeNetworkInterfaces",
                    "ec2:DeleteNetworkInterface",
                    "ec2:AssignPrivateIpAddresses",
                    "ec2:UnassignPrivateIpAddresses",
                    "ec2:DescribeSecurityGroups",
                    "ec2:DescribeSubnets",
                    "ec2:DescribeVpcs"
                ],
                resources=["*"]
            )
        )

        # NetworkInterfaceに対する特定の権限
        lambda_role.add_to_policy(
            iam.PolicyStatement(
                effect=iam.Effect.ALLOW,
                actions=[
                    "ec2:CreateNetworkInterface"
                ],
                resources=["arn:aws:ec2:*:*:network-interface/*"],
            )
        )

        # Subnet、SecurityGroup、VPCに対する権限
        lambda_role.add_to_policy(
            iam.PolicyStatement(
                effect=iam.Effect.ALLOW,
                actions=[
                    "ec2:CreateNetworkInterface"
                ],
                resources=["*"],
                conditions={
                    "ArnEquals": {
                        "ec2:Vpc": vpc.vpc_arn
                    }
                }
            )
        )

        # CloudWatchLogsへの最小限のアクセス権限
        lambda_role.add_to_policy(
            iam.PolicyStatement(
                effect=iam.Effect.ALLOW,
                actions=[
                    "logs:CreateLogGroup",
                    "logs:CreateLogStream",
                    "logs:PutLogEvents"
                ],
                resources=[
                    f"arn:aws:logs:{self.region}:{self.account}:log-group:/aws/lambda/*"
                ]
            )
        )

        # DynamoDBの特定のテーブルに対する読み取り権限のみを付与
        # VPCエンドポイント経由でのアクセスのみを許可
        lambda_role.add_to_policy(
            iam.PolicyStatement(
                effect=iam.Effect.ALLOW,
                actions=[
                    "dynamodb:GetItem",
                    "dynamodb:Query",
                ],
                resources=[user_table.table_arn],
                conditions={
                    "StringEquals": {
                        "aws:SourceVpc": vpc.vpc_id
                    }
                }
            )
        )

        # Lambda関数の作成
        lambda_fn = _lambda.Function(
            self, "UserInfoLambda",
            runtime=_lambda.Runtime.PYTHON_3_9,
            handler="index.handler",
            code=_lambda.Code.from_asset("lambda"),
            environment={
                "TABLE_NAME": user_table.table_name
            },
            vpc=vpc,
            vpc_subnets=ec2.SubnetSelection(
                subnet_type=ec2.SubnetType.PRIVATE_ISOLATED
            ),
            role=lambda_role,
            timeout=Duration.seconds(30),
            code_signing_config=code_signing_config
        )

上記でデプロイするとSecureSubnet1つが存在するVPCと、DynamoDBへのVPCエンドポイント、DynamoDBとLambda関数(コード署名済み)がデプロイされます。

        # コード署名の設定
        # 署名プロファイルの作成
        signing_profile = signer.SigningProfile(
            self, "SigningProfile",
            platform=signer.Platform.AWS_LAMBDA_SHA384_ECDSA,
            signing_profile_name="SecureLambdaSigningProfile"
        )

        # 署名プロファイルからの署名設定
        code_signing_config = _lambda.CodeSigningConfig(
            self, "CodeSigningConfig",
            signing_profiles=[signing_profile],
            untrusted_artifact_on_deployment=_lambda.UntrustedArtifactOnDeployment.ENFORCE
        )

まずは、上記ですがコード署名のためのプロファイルを作成しています。この設定はLambda共通で使えるので、アプリケーションに対して1つ設定してあればよいです。

untrusted_artifact_on_deployment=_lambda.UntrustedArtifactOnDeployment.ENFORCE

この部分でENFORCEを設定しているため、コード署名がされていないコードをアップロード禁止にする設定になります。

        # Lambda関数の作成
        lambda_fn = _lambda.Function(
            self, "UserInfoLambda",
            runtime=_lambda.Runtime.PYTHON_3_9,
            handler="index.handler",
            code=_lambda.Code.from_asset("lambda"),
            environment={
                "TABLE_NAME": user_table.table_name
            },
            vpc=vpc,
            vpc_subnets=ec2.SubnetSelection(
                subnet_type=ec2.SubnetType.PRIVATE_ISOLATED
            ),
            role=lambda_role,
            timeout=Duration.seconds(30),
            code_signing_config=code_signing_config
        )

上記のcode_signing_configの設定を指定するだけで、コード署名が有効化され、デプロイするコードが自動的にコード署名されてデプロイされます。
(私自身これを知るまでは手順が少し面倒くさそうと思っていましたが、CDKによってとても単純になっていて、設定しない理由が見当たらないレベルでした)

        vpc = ec2.Vpc(
            self, "SecureVPC",
            max_azs=2,
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    name="Secure",
                    subnet_type=ec2.SubnetType.PRIVATE_ISOLATED,
                    cidr_mask=24
                )
            ]
        )

VPCの設定において、サブネットのタイプをec2.SubnetType.PRIVATE_ISOLATED と設定することで、インターネットに接点を持たないSecureSubnetを定義することが出来ます。

        vpc.add_gateway_endpoint(
            "DynamoDBEndpoint",
            service=ec2.GatewayVpcEndpointAwsService.DYNAMODB,
            subnets=[ec2.SubnetSelection(
                subnet_type=ec2.SubnetType.PRIVATE_ISOLATED)]
        )

DynamoDBやS3のようにVPC内に配置できないパブリックリソースについては、上記のようにVPCエンドポイントを設定することで、インターネットを介さずに通信を行うことが出来ます。

これだけでセキュアなデプロイと運用を行うLambdaを構築することができます。

Lambdaのコード署名を有効にせずデプロイしている状態では、下記のようにマネジメントコンソール上から直接コードの変更が可能です。

しかし、コード署名を有効化したデプロイを行うと下記のようにコード署名が有効化されている旨の説明が表示され、コードの変更が行えなくなります。

CI/CDに使用しているIAM Roleについては、OIDCによる認証を有効化するなどをしたうえで運用し、それ以外のユーザーについては上記で説明したように、コード署名やIAM等の使用ができないユーザーとすることで、CDKをコマンドライン上から実行してデプロイしたりすることも防ぐことが可能です。

まとめ

今回はLambdaをガバナンスを効かせて安全にデプロイ・運用していくための方法を紹介しました。
記事中でも記載した通り、CDKを使わない形でLambdaのコード署名を行う場合、一度S3にZIP圧縮したコードをデプロイし、それに対してコード署名を施して、そのARNをLambdaに設定するなどが必要となりやや手間が掛かるイメージがありましたが、CDKの場合コード署名のためのプロファイル設定を定義してあれば、Lambda関数ごとに1行のプロパティを設定するのみとなります。しかもコード署名を追加しても費用は掛かりません。
とても手軽にコード署名を施せるため、利用していくことで安全に運用が可能となります。

無料相談実施中
AWSを使用したサーバーレスアプリケーションの構築
サーバーレス開発内製化、AWSへの移行等
様々な課題について無料でご相談お受けいたします。
最新情報をチェックしよう!