1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

ほうき星です。
皆さんはCloudFormationでSecureStringパラメータを直接作成できたら…と思ったことはありませんか?私はあります。何度もあります。
ではここでドキュメントを見てみましょう!!
image.png

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

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

AWS::CloudFormation::CustomResource

皆さんはAWS::CloudFormation::CustomResourceをご存じでしょうか?
このリソースはSNSやLambda関数を組み合わせて、CloudFormation純正では対応していない処理を実行したいときに使用するリソースです。
SNSやLambda関数を呼び出すことができ、下記のようなケースで利用されます。

  • CloudFormationが純正で対応していないAWSリソースの作成
  • 外部サービス等との連携(API呼び出しなど)
  • 特定の初期化処理や登録作業の自動化

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

AWS::CloudFormation::CustomResourceのプロパティ

カスタムリソースは下記のプロパティを持ちます。

  • ServiceTimeout:カスタムリソース操作がタイムアウトするまでの最大時間
  • ServiceToken:呼び出されるSNSトピックのARNやLambda関数のARN

また、ServiceTokenで指定されたLambda関数等にリソース作成のために追加でプロパティを渡すことができます。
今回はAWS::SecretsManager::SecretGenerateSecretStringのような処理を実装したいため、下記のプロパティを設定できるようにします。

  • Name:パラメータ名
  • Description:パラメータの説明
  • Tags:パラメータのタグ
  • GenerateSecureString:SecureString生成のための条件(GenerateSecretStringとほぼ同等)
    • ExcludeCharacters
    • ExcludeLowercase
    • ExcludeNumbers
    • ExcludePunctuation
    • ExcludeUppercase
    • IncludeSpace
    • PasswordLength
    • RequireEachIncludedType

SecureStringなパラメータを作成するLambda関数

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

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

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を指定してください。

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

パラメータストアを見るとSecureStringに条件に応じたランダムな文字列が設定されていることが確認できます。
SecureString.jpg

ユースケース

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

また普通にSecureStringなパラメータであるため、下記のようにDynamic Referencesで参照することも可能です。

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する方法を紹介しました。
誰かの役に立てば幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?