はじめに
ほうき星です。
皆さんはCloudFormationでSecureString
パラメータを直接作成できたら…と思ったことはありませんか?私はあります。何度もあります。
ではここでドキュメントを見てみましょう!!
終
. . . 冗談です。
ですが、ドキュメントに記載があるように、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::Secret
のGenerateSecretString
のような処理を実装したいため、下記のプロパティを設定できるようにします。
- Name:パラメータ名
- Description:パラメータの説明
- Tags:パラメータのタグ
- GenerateSecureString:SecureString生成のための条件(GenerateSecretStringとほぼ同等)
- ExcludeCharacters
- ExcludeLowercase
- ExcludeNumbers
- ExcludePunctuation
- ExcludeUppercase
- IncludeSpace
- PasswordLength
- RequireEachIncludedType
SecureStringなパラメータを作成するLambda関数
カスタムリソースのServiceToken
に指定するLambda関数は同じテンプレート内で作成するか、事前に用意したLambda関数を指定することができます。
今回、Lambda関数は事前に用意することとし、下記コードをSAMでデプロイしました。
SAMテンプレートを含む全ソースは下記リポジトリに置いています、参考にしてください。
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を指定してください。
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
が存在することが確認できます。
パラメータストアを見るとSecureStringに条件に応じたランダムな文字列が設定されていることが確認できます。
ユースケース
SecretsManagerでシークレットを作成した場合は、1シークレットに月0.4ドルかかります。
保存先がSSMパラメータストアでも問題ない場合、その分のコストをケチることができます。
また普通にSecureStringなパラメータであるため、下記のようにDynamic References
で参照することも可能です。
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する方法を紹介しました。
誰かの役に立てば幸いです。