CloudFormationでSecureStringなパラメータをGenerateSecureStringする！

# はじめに
ほうき星です。
皆さんはCloudFormationで`SecureString`パラメータを直接作成できたら…と思ったことはありませんか？私はあります。何度もあります。
ではここでドキュメントを見てみましょう！！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/c0499c20-3ac1-48f4-a065-98c87ca6cbd6.png)

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-ssm-parameter.html

**終**

. . . 冗談です。
ですが、ドキュメントに記載があるように、CloudFormationはSecureStringなパラメータの作成をサポートしていません。
一方、SecretsManagerではSecretStringの作成が可能で、GenerateSecretStringプロパティを利用することで、ランダムなシークレット文字列を自動生成することもできます。ずるい！

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secret.html

そこで本記事ではCloudFormationでSecureStringなパラメータをGenerateSecureStringさせる方法をご紹介します。

# AWS::CloudFormation::CustomResource
皆さんは`AWS::CloudFormation::CustomResource`をご存じでしょうか？
このリソースはSNSやLambda関数を組み合わせて、CloudFormation純正では対応していない処理を実行したいときに使用するリソースです。
SNSやLambda関数を呼び出すことができ、下記のようなケースで利用されます。
* CloudFormationが純正で対応していないAWSリソースの作成
* 外部サービス等との連携(API呼び出しなど)
* 特定の初期化処理や登録作業の自動化

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-customresource.html

今回はこの`AWS::CloudFormation::CustomResource`とLambda関数を使用してSecureStringなパラメータを作成します。

## AWS::CloudFormation::CustomResourceのプロパティ
カスタムリソースは下記のプロパティを持ちます。
* ServiceTimeout：カスタムリソース操作がタイムアウトするまでの最大時間
* ServiceToken：呼び出されるSNSトピックのARNやLambda関数のARN

また、ServiceTokenで指定されたLambda関数等にリソース作成のために追加でプロパティを渡すことができます。
今回は`AWS::SecretsManager::Secret`の`GenerateSecretString`のような処理を実装したいため、下記のプロパティを設定できるようにします。
* Name：パラメータ名
* Description：パラメータの説明
* Tags：パラメータのタグ
* GenerateSecureString：SecureString生成のための条件(GenerateSecretStringとほぼ同等)
    * ExcludeCharacters
    * ExcludeLowercase
    * ExcludeNumbers
    * ExcludePunctuation
    * ExcludeUppercase
    * IncludeSpace
    * PasswordLength
    * RequireEachIncludedType

## SecureStringなパラメータを作成するLambda関数
カスタムリソースの`ServiceToken`に指定するLambda関数は同じテンプレート内で作成するか、事前に用意したLambda関数を指定することができます。
今回、Lambda関数は事前に用意することとし、下記コードをSAMでデプロイしました。

SAMテンプレートを含む全ソースは下記リポジトリに置いています、参考にしてください。

https://github.com/H0ukiStar/cloudformation-securestring-generator

```python: lambda_function.py
from __future__ import print_function
import re
import json
import string
import secrets

import boto3
import urllib3
from pydantic import Field, BaseModel

class TagProperty(BaseModel):
    """
    Represents a tag for the SSM parameter.

    Attributes
    ----------
    Key : str
        Tag key.
    Value : str
        Tag value.
    """
    Key: str = Field(..., description="Tag key")
    Value: str = Field(..., description="Tag value")

class GenerateSecureStringProperty(BaseModel):
    """
    Properties for generating a secure string password.

    Attributes
    ----------
    ExcludeCharacters : str
        Characters to exclude from the generated string.
    ExcludeLowercase : bool
        Exclude lowercase letters.
    ExcludeNumbers : bool
        Exclude numbers.
    ExcludePunctuation : bool
        Exclude punctuation.
    ExcludeUppercase : bool
        Exclude uppercase letters.
    IncludeSpace : bool
        Include space in the generated string.
    PasswordLength : int
        Length of the generated string.
    RequireEachIncludedType : bool
        Require at least one character from each included type.
    """
    ExcludeCharacters: str = Field(default="", description="Exclude characters from the generated string")
    ExcludeLowercase: bool = Field(default=False, description="Exclude lowercase letters from the generated string")
    ExcludeNumbers: bool = Field(default=False, description="Exclude numbers from the generated string")
    ExcludePunctuation: bool = Field(default=False, description="Exclude punctuation from the generated string")
    ExcludeUppercase: bool = Field(default=False, description="Exclude uppercase letters from the generated string")
    IncludeSpace: bool = Field(default=False, description="Include space in the generated string")
    PasswordLength: int = Field(default=32, ge=1, le=4096, description="Length of the generated string")
    RequireEachIncludedType: bool = Field(default=False, description="Require each included type in the generated string")

class SecureStringProperties(BaseModel):
    """
    Properties for the secure string SSM parameter.

    Attributes
    ----------
    Name : str
        Name of the SSM parameter.
    Description : str
        Description of the parameter.
    Tags : list of TagProperty
        List of tags to assign to the parameter.
    GenerateSecureString : GenerateSecureStringProperty
        Properties for generating the secure string value.
    """
    Name: str
    Description: str = ""
    Tags: list[TagProperty] = Field(default=[], description="Tags for the secure string")
    GenerateSecureString: GenerateSecureStringProperty = Field(
        default=GenerateSecureStringProperty(),
        description="Properties for generating a secure string"
    )

def generate_password(property: GenerateSecureStringProperty) -> str:
    """
    Generate a secure password string based on the given properties.

    Parameters
    ----------
    property : GenerateSecureStringProperty
        The properties for password generation.

    Returns
    -------
    str
        The generated password string.

    Raises
    ------
    ValueError
        If no characters are available for password generation.
    """
    pool: str = ""
    required_chars: list[str] = []

    if not property.ExcludeLowercase:
        pool += string.ascii_lowercase
        if property.RequireEachIncludedType:
            required_chars.append(secrets.choice(string.ascii_lowercase))

    if not property.ExcludeUppercase:
        pool += string.ascii_uppercase
        if property.RequireEachIncludedType:
            required_chars.append(secrets.choice(string.ascii_uppercase))

    if not property.ExcludeNumbers:
        pool += string.digits
        if property.RequireEachIncludedType:
            required_chars.append(secrets.choice(string.digits))

    if not property.ExcludePunctuation:
        pool += string.punctuation
        if property.RequireEachIncludedType:
            required_chars.append(secrets.choice(string.punctuation))

    if property.IncludeSpace:
        pool += " "
        if property.RequireEachIncludedType:
            required_chars.append(" ")

    # Remove excluded characters from the pool
    pool = "".join(c for c in pool if c not in property.ExcludeCharacters)

    if not pool:
        raise ValueError("No characters available for password generation")

    # Generate the password
    password: list[str] = [secrets.choice(pool) for _ in range(property.PasswordLength - len(required_chars))]
    password += required_chars
    secrets.SystemRandom().shuffle(password)
    return "".join(password)

def lambda_handler(event: dict, context: object) -> None:
    """
    Lambda entry point for handling CloudFormation custom resource events.

    Parameters
    ----------
    event : dict
        The event data from CloudFormation.
    context : object
        The Lambda context object.

    Returns
    -------
    None
    """
    ssm_client = boto3.client("ssm")

    try:
        securestring_properties: SecureStringProperties = SecureStringProperties(**event["ResourceProperties"])
    except Exception as e:
        send(event, context, "FAILED", {}, reason=f"Invalid ResourceProperties: {e}")
        return

    if event["RequestType"] == "Create":
        try:
            secret: str = generate_password(securestring_properties.GenerateSecureString)
        except Exception as e:
            send(event, context, "FAILED", {}, reason=f"Failed to generate password: {e}")
            return

        try:
            # Create the SSM parameter as SecureString
            ssm_client.put_parameter(
                Name=securestring_properties.Name,
                Description=securestring_properties.Description,
                Value=secret,
                Type="SecureString",
                Tags=[tag.model_dump() for tag in securestring_properties.Tags],
                Overwrite=False
            )
            send(event, context, "SUCCESS", {}, physicalResourceId=securestring_properties.Name)
            return
        except ssm_client.exceptions.ParameterAlreadyExists:
            send(event, context, "FAILED", {}, reason=f"Parameter {securestring_properties.Name} already exists")
            return
    elif event["RequestType"] == "Delete":
        physical_resource_id = event["PhysicalResourceId"]
        try:
            # Delete the SSM parameter
            ssm_client.delete_parameter(Name=physical_resource_id)
            send(event, context, "SUCCESS", {}, physicalResourceId=physical_resource_id)
            return
        except ssm_client.exceptions.ParameterNotFound:
            # Parameter already deleted, treat as success
            send(event, context, "SUCCESS", {}, physicalResourceId=physical_resource_id)
            return
    else:
        # For Update or other request types, just return success
        send(event, context, "SUCCESS", {})
        return

# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0

http = urllib3.PoolManager()

def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None):
    responseUrl = event['ResponseURL']

    responseBody = {
        'Status' : responseStatus,
        'Reason' : reason or "See the details in CloudWatch Log Stream: {}".format(context.log_stream_name),
        'PhysicalResourceId' : physicalResourceId or context.log_stream_name,
        'StackId' : event['StackId'],
        'RequestId' : event['RequestId'],
        'LogicalResourceId' : event['LogicalResourceId'],
        'NoEcho' : noEcho,
        'Data' : responseData
    }

    json_responseBody = json.dumps(responseBody)

    print("Response body:")
    print(json_responseBody)

    headers = {
        'content-type' : '',
        'content-length' : str(len(json_responseBody))
    }

    try:
        response = http.request('PUT', responseUrl, headers=headers, body=json_responseBody)
        print("Status code:", response.status)


    except Exception as e:

        print("send(..) failed executing http.request(..):", mask_credentials_and_signature(e))


def mask_credentials_and_signature(message):
    message = re.sub(r'X-Amz-Credential=[^&\s]+', 'X-Amz-Credential=*****', message, flags=re.IGNORECASE)
    return re.sub(r'X-Amz-Signature=[^&\s]+', 'X-Amz-Signature=*****', message, flags=re.IGNORECASE)

```

> send関数やmask_credentials_and_signature関数は下記ドキュメントに記載がある通りです。
> https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html#cfn-lambda-function-code-cfnresponsemodule-source-python

# CloudFormationでSecureStringなパラメータをGenerateしてみる
下記テンプレートでGenerateSecureStringの動作を確認してみます。
※`<事前用意したLambda関数のARN>`はご自身のLambda関数のARNを指定してください。
```yaml: template.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "SecureString Parameter resource cloudformation template"

Resources:
  CustomResourceSecureString:
    Type: "AWS::CloudFormation::CustomResource"
    Properties:
      ServiceTimeout: 30
      ServiceToken: <事前用意したLambda関数のARN>
      Name: "test-securestring"
      Tags:
        - Key: "Environment"
          Value: "test"
      GenerateSecureString:
        PasswordLength: 16

```

スタックを作成するとリソースタブに`test-securestring`が存在することが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/fd6095d1-e1ae-452b-91b8-1618aee239b9.png)

パラメータストアを見るとSecureStringに条件に応じたランダムな文字列が設定されていることが確認できます。
![SecureString.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/22cc3195-7a1e-4612-9b20-f60a2b4d2783.jpeg)

# ユースケース
SecretsManagerでシークレットを作成した場合は、1シークレットに月0.4ドルかかります。
保存先がSSMパラメータストアでも問題ない場合、その分のコストをケチることができます。

また普通にSecureStringなパラメータであるため、下記のように`Dynamic References`で参照することも可能です。
```yaml: template.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "SecureString Parameter resource cloudformation template"

Resources:
  CustomResourceSecureString:
    Type: "AWS::CloudFormation::CustomResource"
    Properties:
      ServiceTimeout: 30
      ServiceToken: <事前用意したLambda関数のARN>
      Name: "test-securestring"
      Tags:
        - Key: "Environment"
          Value: "test"
      GenerateSecureString:
        PasswordLength: 16

  UserTest:
    Type: "AWS::IAM::User"
    Properties:
      UserName: "test-user"
      LoginProfile:
        Password: !Sub "{{resolve:ssm-secure:${CustomResourceSecureString}}}"

```

> Dynamic References可能なプロパティは下記のドキュメントを参考にしてください。
> https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/dynamic-references-ssm-secure-strings.html#template-parameters-dynamic-patterns-resources

# さいごに
この記事ではCloudFormation純正では作成できないSecureStringパラメータを、GenerateSecretStringと同じ使用感でGenerateSecureStringする方法を紹介しました。
誰かの役に立てば幸いです。