AWS 노트

Prameter Store를 이용해 CDK stack에서 하드코드 없애기

Jonchann 2020. 4. 17. 16:21

하드코딩이라 함은 소스코드 내부에 밖으로 새어 나가서는 안되는 정보(e.g. accesskey, secretaccesskey, host, wdbhook url 등)가 적혀있는 것을 말한다.

 

로컬에서 개발하는 단계에는 그런 정보가 있으나 없으나 상관은 없겠다만 commit & push를 해 버리면 위험하다.

git이나 github 레포지토리에 그 이력이 남기 때문에 악의를 가진 사람들이 작정하고 알아내려 한다면 알아낼 수 있을거고 그런 위험에 대비하기 위해서는 commit해버린 정보를 파기하고 그 정보와 관련된 모든 서비스 혹은 프로그램, 소프트웨어 등을 새로 정보설정해야하는 큰일이 벌어질 것이다.

 

하지만 아무리 머리 속으로 생각해도 간혹 이런 중요한 방침을 잊어버릴 일도 있지 않겠는가..(내가 방금 저 짓을 했다..)
그런 의미에서 AWS 서비스를 사용할 때 Parameter Store로 기밀정보를 지키는 것을 추천한다.

commit할 때 같이 올라갈 일도 없고 내가 commit할 때마다 마스크를 씌울 일도 없어지니까 말이다.

Parameter Store의 파라미터 이름 정하기

어떤 프로젝트에서 어디에 접속하기 위한 무슨 정보인지를 계층 구조 형식(path)으로 적어주어야 한다: /[project_name]/[AWS_service_name]/[classified_info]

  • e.g. /query-to-slack/redshift/password

계층 구조 형식으로 파라미터 이름을 정하는 것에 대한 더 자세한 정보를 아래 링크를 참고하면 된다:
AWS 설명서: 파라미터를 계층 구조로 조직

 

예를 들어 Redshift에 접속하고 싶다고 할 때 host, database_name, password, user_name등의 정보를 Parameter Store에 격납해 놓고 참조해야 할 것이다.

port 번호는 기본을 사용하니까 그냥 써 놓아도 된다.

 

개발환경과 프로덕션환경 양쪽에 같은 이름으로 파라미터를 만들어주면 검증할 때나 실제 사용하게 되었을 때나 코드를 수정할 일 없이 access 정보를 취득할 수 있다.
파라미터의 값(value)은 중복이 되어도 상관이 없는데 대신 그 값을 불러오는 키(key)는 달라야 한다.

aws cli로 파라미터 작성하기


보안되지 않은 문자열로 파라미터 작성하기

$ aws ssm put-parameter --name "[parameter_name]" --value "[classified_info]" --type String

Parameter Store의 콘솔 화면에서 보면 아래와 같이 파라미터가 작성되어있고 그 값은 가려져있지 않다.

cli로 저장된 값을 가져와보면

$ aws ssm get-parameter --name "/PROJECT_NAME/AWS_SERVICE/CLASSIFIED_INFO"

아래와 같은 정보를 얻을 수 있다.

{
    "Parameter": {
        "Name": "/PROJECT_NAME/AWS_SERVICE_NAME/CLASSIFIED_INFO",
        "Type": "String",
        "Value": "5439",
        "Version": VERSION_INTEGER,
        "LastModifiedDate": "DATE",
        "ARN": "PARAMETER_ARN"
    }
}

보안된 문자열로 파라미터 작성하기

$ aws ssm put-parameter --name "[parameter_name]" --value "[classified_info]" --type "SecureString"  // 혹은
$ aws ssm put-parameter --name "[parameter_name]" --value "[classified_info]" --type SecureString

Parameter Store의 콘솔 화면에서 보면 아래와 같이 파라미터가 작성되어 있고 그 값은 (물론 표시를 누르면 보이지만) 일단 ***와 같은 식으로 가려져있다.

cli로 저장된 값을 가져와보면

$ aws ssm get-parameter --name "/PROJECT_NAME/AWS_SERVICE/CLASSIFIED_INFO"

위와 다르게 값이 암호화 되어 있는 것을 알 수 있다.

{
    "Parameter": {
        "Name": "/PROJECT_NAME/AWS_SERVICE_NAME/CLASSIFIED_INFO",
        "Type": "SecureString",
        "Value": "ENCODED_VALUE_STRING",
        "Version": VERSION_INTEGER,
        "LastModifiedDate": "DATE",
        "ARN": "PARAMETER_ARN"
    }
}

CDK stack안에서 접근 정보 참조하기


결론부터 말하자면 후보4를 채용했다

aws cdk의 github: RFC: Have CDK put SecureString type parameter values into SSM securely에 따르면(19년 8월 기준) SecureString 타입의 값에 대해서 System Manager(ssm)은 CDK 내에서 사용을 못하게 하고 있으며 ssm을 통해 CDK stack에 값을 전달하는 방법은 aws-sdk라고 한다. 참고로 String은 직접 호출 가능하다.
이러한 이유로는 CloudFormation이 SecureString을 지원하지 않기 때문이라고 한다.

What is the current behavior?
Currently there is no way to put a SecureSAtring type value into the System Manager Parameter store using CDK.
The only method to put a secure param in ssm is in the aws sdk.
The only way to access aws sdk in cdk is some sort of custom construct.

그 반면 SecretManager(sm)의 Secret은 모든 서비스를 위해 CDK내부에서 직접 호출해서 사용하는 것이 가능하다. 그만큼 비싸다는 것 같지만.

Yes, SSM SecureStrings aren't supported everywhere. SecretsManager Secrets are supported everywhere, so those would be recommended (though they are more expensive, unfortunately).
As for the reason why CDK doesn't write those secrets for you... we considered it but decided that handling user's secrets would be a responsibility that would eat a lot of engineering time to do properly, and we didn't want to spend that time at that point in time.

그러므로 예정대로 ssm의 SecureString을 CDK내에서 환경변수로 호출해 사용할 수 있는 법에 대해 알아보겠다.

후보1. cdk.SecureValue.ssmSecure()

먼저, cdk.SecureValue.ssmSecure('parameter_name', 'version')을 발견했다.

Using AWS CDK, how can I set the oathToken for source in code pipeline, to pull sourcecode from GitHub, without using Secret Manager service?
Ask

CDK .NET Reference: Class SecretValue

AWS CDK Document: class SecretValue

이 메소드는 cdk.SecureValue를 반환한다. .toString()이나 .toJSON()을 이용할 수 있다.

.toString()으로 cdk.SecureValue개체의 내부를 확인하면

먼저, cdk.SecureValue값으로 반환한다는게 도대체 뭔지 감이 안와서 한 번 출력해보았다.
예상대로 보안 문자열을 불러오는 것이니까 봐도 모르게끔 적혀 있는게 당연하겠지만.

SecretValue {
  creationStack: [
    'new Intrinsic (/YOUR_LOCAL_PATH/node_modules/@aws-cdk/core/lib/private/intrinsic.ts:28:26)',
    'new SecretValue (/YOUR_LOCAL_PATH/node_modules/@aws-cdk/core/lib/secret-value.ts:20:1)',
    'Function.cfnDynamicReference (/YOUR_LOCAL_PATH/node_modules/@aws-cdk/core/lib/secret-value.ts:81:12)',
    'Function.ssmSecure (/YOUR_LOCAL_PATH/node_modules/@aws-cdk/core/lib/secret-value.ts:70:17)',
    'new SlowQueryCheckerStack (/YOUR_LOCAL_PATH/lib/slow_query_checker-stack.ts:31:33)',
    'Object.<anonymous> (/YOUR_LOCAL_PATH/bin/slow_query_checker.ts:7:1)',
    'Module._compile (internal/modules/cjs/loader.js:1158:30)',
    'Module.m._compile (/YOUR_LOCAL_PATH/node_modules/ts-node/src/index.ts:836:23)',
    'Module._extensions..js (internal/modules/cjs/loader.js:1178:10)',
    'Object.require.extensions.<computed> [as .ts] (/YOUR_LOCAL_PATH/node_modules/ts-node/src/index.ts:839:12)',
    'Module.load (internal/modules/cjs/loader.js:1002:32)',
    'Function.Module._load (internal/modules/cjs/loader.js:901:14)',
    'Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:74:12)',
    'main (/YOUR_LOCAL_PATH/node_modules/ts-node/src/bin.ts:226:14)',
    'Object.<anonymous> (/YOUR_LOCAL_PATH/node_modules/ts-node/src/bin.ts:485:3)',
    'Module._compile (internal/modules/cjs/loader.js:1158:30)',
    'Object.Module._extensions..js (internal/modules/cjs/loader.js:1178:10)',
    'Module.load (internal/modules/cjs/loader.js:1002:32)',
    'Function.Module._load (internal/modules/cjs/loader.js:901:14)',
    'Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:74:12)',
    '/usr/local/lib/node_modules/npm/node_modules/libnpx/index.js:268:14'
  ],
  value: CfnDynamicReference {
    creationStack: [
      'new Intrinsic (/YOUR_LOCAL_PATH/node_modules/@aws-cdk/core/lib/private/intrinsic.ts:28:26)',
      'new CfnDynamicReference (/YOUR_LOCAL_PATH/node_modules/@aws-cdk/core/lib/cfn-dynamic-reference.ts:28:5)',
      'Function.ssmSecure (/YOUR_LOCAL_PATH/node_modules/@aws-cdk/core/lib/secret-value.ts:70:37)',
      'new SlowQueryCheckerStack (/YOUR_LOCAL_PATH/lib/slow_query_checker-stack.ts:31:33)',
      'Object.<anonymous> (/YOUR_LOCAL_PATH/bin/slow_query_checker.ts:7:1)',
      'Module._compile (internal/modules/cjs/loader.js:1158:30)',
      'Module.m._compile (/YOUR_LOCAL_PATH/node_modules/ts-node/src/index.ts:836:23)',
      'Module._extensions..js (internal/modules/cjs/loader.js:1178:10)',
      'Object.require.extensions.<computed> [as .ts] (/YOUR_LOCAL_PATH/node_modules/ts-node/src/index.ts:839:12)',
      'Module.load (internal/modules/cjs/loader.js:1002:32)',
      'Function.Module._load (internal/modules/cjs/loader.js:901:14)',
      'Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:74:12)',
      'main (/YOUR_LOCAL_PATH/node_modules/ts-node/src/bin.ts:226:14)',
      'Object.<anonymous> (/YOUR_LOCAL_PATH/node_modules/ts-node/src/bin.ts:485:3)',
      'Module._compile (internal/modules/cjs/loader.js:1158:30)',
      'Object.Module._extensions..js (internal/modules/cjs/loader.js:1178:10)',
      'Module.load (internal/modules/cjs/loader.js:1002:32)',
      'Function.Module._load (internal/modules/cjs/loader.js:901:14)',
      'Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:74:12)',
      '/usr/local/lib/node_modules/npm/node_modules/libnpx/index.js:268:14'
    ],
    value: '{{resolve:ssm-secure:PROJECT_NAME/AWS_SERVICE/CLASSIFIED_INFO:VERSION}}'
  }
}

아마 value: '{{resolve:ssm-secure:PROJECT_NAME/AWS_SERVICE/CLASSIFIED_INFO:VERSION}}'부분이 SecureString의 값을 나타내고 있고 이것을 aws-sdk를 통해 전달해야 되는 것으로 보인다.
이것이 CDK내부에서는 직접 호출 불가한 것이고.

.toJSON()으로 내부를 확인하면

Object.keys()

[
  '0',  '1',  '2',  '3',
  '4',  '5',  '6',  '7',
  '8',  '9',  '10', '11',
  '12', '13', '14', '15',
  '16', '17'
]

Object.values()

[
  '<', 'u', 'n', 'r', 'e',
  's', 'o', 'l', 'v', 'e',
  'd', '-', 't', 'o', 'k',
  'e', 'n', '>'
]

아무 것도 확인할 수 없다.

CDK .NET Reference에 따르면 cdk.SecureValue는 디플로이 할 때에만 이용 가능한(?) 다른 값들처럼 정규 문자열이면서 SecretManager에서부터 값을 가져온다고 한다.
잘못 기밀 정보 채로 CloudFormation에서 처리되지 않도록 Secret.asserSafeSecret()을 호출해 해결한다.
Secret.plainText()를 호출해 확인해볼 수도 있지만 추천하지 않는다고 한다.

Secret values in the CDK (such as those retrieved from SecretsManager) are represented as regular strings, just like other values that are only available at deployment time.
Secret values in the CDK (such as those retrieved from SecretsManager) are represented as regular strings, just like other values that are only available at deployment time.
To help you avoid accidental mistakes which would lead to you putting your secret values directly into a CloudFormation template, constructs that take secret values will not allow you to pass in a literal secret value. They do so by calling Secret.assertSafeSecret().
You can escape the check by calling Secret.plainText(), but doing so is highly discouraged.

결론

Visual Studio Code에서 내부 설명(secret-value.d.ts)을 읽어봤더니 실제 상황에서 보안을 중시해야 하는 정보를 취급할 때는 사용하지 말고 테스트 할 때에나 사용하라고 한다.
어째서 공식 도큐멘트에는 적어놓지 않아서 이렇게 삽질을 하게 한건지ㅎ

/*
* Construct a literal secret value for use with secret-aware constructs
*
* *Do not use this method for any secrets that you care about.

*
* The only reasonable use case for using this method is when you are testing.
*/

또한, sdk를 통하는 법을 찾지 못했다..

후보2. ssm.ParameterStoreSecureString()

aws의 공식 github: Create how-to on using secrets에서 소개된 방법이다.

결론

ssm.ParameterStoreSecureString()은 존재하지 않았다.

후보3. @aws-cdk/aws-ssm + aws-sdk

[AWS CDK] SSMのパラメータストアにあるStringとSecure StringをLambdaで使ってみた를 참고하면 된다.

결론

나는 CDK stack파일 빼고는 다 Python으로 구현했기 때문에 이 방법은 패스한다.

후보4. boto3.client('ssm').get_parameter()

@aws-cdk/aws-ssmaws-sdk를 사용하는 방법도 있지만 aws-sdklambda_handler 속에서 사용해야 하기 때문에 포기했다.

나는 lambda_handler를 Python으로 구현했고 aws-sdk는 TypeScript 라이브러리이기 때문이다.

 

[AWS CDK] SSMのパラメータストアにあるStringとSecure StringをLambdaで使ってみた에서 구현한 Lambda함수 코드를 보면 아래와 같은 형식으로 구현한 것을 확인할 수 있다.

import * as AWS from 'aws-sdk';

const ssm = new AWS.SSM();

export async function handler(event: any) {
  const id = event.pathParameters.id;

  var ssmSecureParam1 = await ssm.getParameter({
    Name: '/PROJECT_NAME/AWS_SERVICE/CLASSIFIED_INFO',
    WithDecryption: true,
  }).promise();

boto3에도 이것이 가능할 것이다.

 

boto3 ssm으로 검색한 결과 https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameter를 찾을 수 있었다.

게다가 TypeScript는 lambda_handler.tsaws-sdk로 코드를 작성하고 cdk_stack.ts@aws-cdk/aws-ssm으로 또 코드를 작성하는데 boto3lambda_handler.py에서만 해결볼 수 있다.

import boto3


def get_secure_parameter(self, key: str):
        client = boto3.client('ssm')
        return client.get_parameter(
            Name=f"/slow-query/redshift/{key}",
            WithDecryption=True
            )["Parameter"]['Value']

Lambda함수가 Parameter Store에서 파라미터 참조할 수 있는 역할 부여하기


컴파일하고 디플로이 한 뒤 콘솔화면에서 Lambda함수를 실행해보면 아래와 같이 접근 거부 에러나 난다.

"errorMessage": "An error occurred (AccessDeniedException) when calling the GetParameter operation: User: arn:aws:sts::644974850401:assumed-role/SlowQueryCheckerStack-SlowQueryCheckerlambdaServic-NRXKQ6ICWSTO/SlowQueryCheckerStack-SlowQueryCheckerlambdaC49DD6-1UK7YCDBHKGEQ is not authorized to perform: ssm:GetParameter on resource: arn:aws:ssm:ap-northeast-1:644974850401:parameter/slow-query/redshift/database"

에러문을 읽어보면is not authorized to perform: ssm:GetParameter on resource라고 되어 있는 것을 알 수 있다.
그러니 "ssm:GetParameter"권한을 추가하기로 하자.

콘솔에서 새로운 정책 attach하면 안된다

CDK로 구현한 인프라에 콘솔에서 새로운 것을 추가하면 CDK가 관리할 수 없는 외부 리소스를 섞어버리는 것과 같다.
따라서 처음에 새로운 정책을 콘솔에서 만들어 이미 Lambda함수에 부여된 역할에 attach했는데 삭제하고 다시 CDK에서 추가해야 했다.

 

CDK stack을 아주 처음 생성할 때 함께 생성된 역할과 attach된 2개의 정책(AWS가 필요하다고 판단한 VPC, CloudWatch 사용을 위한 최소한의 권한)은 이미 Lambda함수에 연결되어 있었다.
lambda.Function에 따로 연결시키지 않아도(role: [role]) 작동은 잘 되고 있었다.

 

여기서 궁금했던 것은 저 역할을 CDK코드로 가져와서 새로운 정책을 attach하고 lambda.Function에 정보를 전달했을 때 이미 연결되어 있는 역할이 갱신되고 끝나는지 새로운 정책이 붙었다고 에러가 날지 하는 것이었다.

 

처음부터 새로 CDK stack내부에서 역할을 생성해 정책을 attach하는 것이 좋겠다 싶어 cdk destroy를 실행했다.

 

콘솔에서 작성해서 attach했던 정책은 미리 detach하거나 삭제하지 않으면 Cannot delete entity, must detach all policies first.라는 에러가 뜨면서 cdk destroy에 실패하니 주의해야 한다.

cdk destroy를 하니 처음에 함께 생성된 정책과 역할 모두 삭제되었다.

CDK stack에 새롭게 역할을 생성하고 정책 statement를 추가하기

cdk destroy를 한다고 로컬에 적은 CDK stack이 사라지는 것은 아니다.
그러니 CDK stack내부에 새로운 역할을 적어주면 된다.

const lambdaRole = new Role(this, 'lambdaRole', {
      assumedBy: new ServicePrincipal('lambda.amazonaws.com')
    });

이 역할은 Lambda함수 실행을 위한 것이기 때문에 실행자를 'lambda.amazonaws.com'로 해준다.
다른 서비스로 실행하는 것이라면 다른 것을 적어주면 된다.

 

정책 statement를 추가한다.

role.addToPolicy(new PolicyStatement({
    resources: ['arn:aws:ssm:[region]:[account_info]:parameter/[project_name]/*'],  // 오직 이 프로젝트에 필요한 파라미터에만 접근할 수 있도록 리소스를 제한
    actions: ['ssm:GetParameter']
    }));

    role.addToPolicy(new PolicyStatement({
    resources: ['*'],
    actions: [  // 이 권한이 없으면 deploy시 'The provided execution role does not have permissions to call CreateNetworkInterface on EC2' 에러가 발생하면서 실패
        'ec2:DescribeNetworkInterfaces', 
        'ec2:CreateNetworkInterface', 
        'ec2:DeleteNetworkInterface']
    }));

    role.addToPolicy(new PolicyStatement({
    resources: ['*'],
    actions: [  // CloudWatch 사용을 위한 권한
    'logs:CreateLogGroup', 
    'logs:CreateLogStream',
    'logs:DescribeLogStreams',
    'logs:PutLogEvents']
    }));

VPC권한은 AWS Lambda:The provided execution role does not have permissions to call DescribeNetworkInterfaces on EC2 를 참고했고 CloudWatch권한은 CloudWatch Logs IAM 역할 만들기를 참고했다.

 

하지만 위의 코드를 CDK stack안에 주저리 주저리 적었더니 코드가 보기싫어졌다.
함수로 만들어서 import한다.

import { Role, PolicyStatement } from '@aws-cdk/aws-iam'

export function addPolicies(role: Role): void {
    role.addToPolicy(new PolicyStatement({
    resources: ['arn:aws:ssm:[region]:[account_info]:parameter/[project_name]/*'],
    actions: ['ssm:GetParameter'] 
    }));

    role.addToPolicy(new PolicyStatement({
    resources: ['*'],
    actions: [
        'ec2:DescribeNetworkInterfaces', 
        'ec2:CreateNetworkInterface', 
        'ec2:DeleteNetworkInterface']
    }));

    role.addToPolicy(new PolicyStatement({
    resources: ['*'],
    actions: [
    'logs:CreateLogGroup', 
    'logs:CreateLogStream',
    'logs:DescribeLogStreams',
    'logs:PutLogEvents']
    }));
}
import { addPolicies } from "../src/add-policies"

const lambdaRole = new Role(this, 'lambdaRole', {
      assumedBy: new ServicePrincipal('lambda.amazonaws.com')
    });

addPolicies(lambdaRole);

무사히 성공했다.