From 86a298514edbf27a3e12eaffc84af6b57a1a8d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Tue, 18 Dec 2018 11:22:48 +0100 Subject: [PATCH 1/9] feat: Allow update of PGPSecret and PrivateKey Updated the python code that backs the custom resource implementations for the PGPSecret and PrivateKey constructs so they allow updating the KMS encryption keys used by AWS SecretsManager without necessarily re-generating new secrets. Also, added a `resourceVersion` has to the properties so the resource handler is called again if the implementation code changed. BREAKING CHANGE: this also changes the API of the PGPSecret and CodeSigningCertificate constructs to offer a consistent API for accessing the name and ARNs of the secret and parameters associated with the secrets, through the `ICredentialPair` interface. --- .../certificate-signing-request.ts | 5 +- lib/code-signing/code-signing-certificate.ts | 47 ++-- lib/code-signing/private-key.ts | 22 +- .../{private-key.py => private-key/index.py} | 46 +++- lib/credential-pair.ts | 35 +++ lib/pgp-secret.ts | 39 ++- lib/pgp-secret/index.py | 156 ++++++++++++ lib/pgpresource.py | 88 ------- lib/publishing.ts | 6 +- lib/util.ts | 26 ++ test/expected.json | 238 +++++------------- test/pgp-secret.test.ts | 2 +- 12 files changed, 380 insertions(+), 330 deletions(-) rename lib/code-signing/{private-key.py => private-key/index.py} (67%) create mode 100644 lib/credential-pair.ts create mode 100644 lib/pgp-secret/index.py delete mode 100644 lib/pgpresource.py diff --git a/lib/code-signing/certificate-signing-request.ts b/lib/code-signing/certificate-signing-request.ts index 14a7ecf6..9911241f 100644 --- a/lib/code-signing/certificate-signing-request.ts +++ b/lib/code-signing/certificate-signing-request.ts @@ -2,6 +2,7 @@ import cfn = require('@aws-cdk/aws-cloudformation'); import lambda = require('@aws-cdk/aws-lambda'); import cdk = require('@aws-cdk/cdk'); import path = require('path'); +import { hashFileOrDirectory } from '../util'; import { RsaPrivateKeySecret } from './private-key'; export interface CertificateSigningRequestProps { @@ -46,13 +47,14 @@ export class CertificateSigningRequest extends cdk.Construct { constructor(parent: cdk.Construct, id: string, props: CertificateSigningRequestProps) { super(parent, id); + const codeLocation = path.join(__dirname, 'certificate-signing-request'); const customResource = new lambda.SingletonFunction(this, 'ResourceHandler', { uuid: '541F6782-6DCF-49A7-8C5A-67715ADD9E4C', lambdaPurpose: 'CreateCSR', description: 'Creates a Certificate Signing Request document for an x509 certificate', runtime: lambda.Runtime.Python36, handler: 'index.main', - code: new lambda.AssetCode(path.join(__dirname, 'certificate-signing-request')), + code: new lambda.AssetCode(codeLocation), timeout: 300, }); @@ -60,6 +62,7 @@ export class CertificateSigningRequest extends cdk.Construct { lambdaProvider: customResource, resourceType: 'Custom::CertificateSigningRequest', properties: { + resourceVersion: hashFileOrDirectory(codeLocation), // Private key privateKeySecretId: props.privateKey.secretArn, privateKeySecretVersion: props.privateKey.secretVersion, diff --git a/lib/code-signing/code-signing-certificate.ts b/lib/code-signing/code-signing-certificate.ts index 30371b3c..33f3a03f 100644 --- a/lib/code-signing/code-signing-certificate.ts +++ b/lib/code-signing/code-signing-certificate.ts @@ -2,6 +2,7 @@ import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import ssm = require('@aws-cdk/aws-ssm'); import cdk = require('@aws-cdk/cdk'); +import { ICredentialPair } from '../credential-pair'; import permissions = require('../permissions'); import { DistinguishedName } from './certificate-signing-request'; import { RsaPrivateKeySecret } from './private-key'; @@ -53,24 +54,7 @@ interface CodeSigningCertificateProps { distinguishedName: DistinguishedName; } -export interface ICodeSigningCertificate { - /** - * The ARN of the AWS Secrets Manager secret that holds the private key for - * this CSC - */ - privateKeySecretArn: string; - - /** - * The ID of the version of the AWS Secrets Manager secret that holds the - * private key for this CSC - */ - privateKeySecretVersionId: string; - - /** - * The name of the AWS SSM parameter that holds the certificate for this CSC. - */ - certificateParameterName: string; - +export interface ICodeSigningCertificate extends ICredentialPair { /** * Grant the IAM principal permissions to read the private key and * certificate. @@ -97,23 +81,22 @@ export class CodeSigningCertificate extends cdk.Construct implements ICodeSignin /** * The ARN of the AWS Secrets Manager secret that holds the private key for this CSC */ - public readonly privateKeySecretArn: string; + public readonly secretArn: string; /** * The ID of the version of the AWS Secrets Manager secret that holds the private key for this CSC */ - - public readonly privateKeySecretVersionId: string; + public readonly secretVersionId: string; /** - * The name of the AWS SSM parameter that holds the certificate for this CSC. + * The ARN of the AWS SSM Parameter that holds the certificate for this CSC. */ - public readonly certificateParameterName: string; + public readonly parameterArn: string; /** - * The ARN of the AWS SSM Parameter that holds the certificate for this CSC. + * The name of the AWS SSM parameter that holds the certificate for this CSC. */ - private readonly certificateParameterArn: string; + public readonly parameterName: string; /** * KMS key to encrypt the secret. @@ -140,8 +123,8 @@ export class CodeSigningCertificate extends cdk.Construct implements ICodeSignin this.secretEncryptionKey = props.secretEncryptionKey; - this.privateKeySecretArn = privateKey.secretArn; - this.privateKeySecretVersionId = privateKey.secretVersion; + this.secretArn = privateKey.secretArn; + this.secretVersionId = privateKey.secretVersion; let certificate = props.pemCertificate; @@ -163,16 +146,16 @@ export class CodeSigningCertificate extends cdk.Construct implements ICodeSignin } const paramName = `${baseName}/Certificate`; - this.certificateParameterName = `/${paramName}`; + this.parameterName = `/${paramName}`; new ssm.cloudformation.ParameterResource(this, 'Resource', { description: `A PEM-encoded Code-Signing Certificate (private key in ${privateKey.secretArn} version ${privateKey.secretVersion})`, - name: this.certificateParameterName, + name: this.parameterName, type: 'String', value: certificate }); - this.certificateParameterArn = cdk.ArnUtils.fromComponents({ + this.parameterArn = cdk.ArnUtils.fromComponents({ service: 'ssm', resource: 'parameter', resourceName: paramName @@ -188,11 +171,11 @@ export class CodeSigningCertificate extends cdk.Construct implements ICodeSignin permissions.grantSecretRead({ keyArn: this.secretEncryptionKey && this.secretEncryptionKey.keyArn, - secretArn: this.privateKeySecretArn, + secretArn: this.secretArn, }, principal); principal.addToPolicy(new iam.PolicyStatement() .addAction('ssm:GetParameter') - .addResource(this.certificateParameterArn)); + .addResource(this.parameterArn)); } } diff --git a/lib/code-signing/private-key.ts b/lib/code-signing/private-key.ts index ceb4dce0..7fb3e181 100644 --- a/lib/code-signing/private-key.ts +++ b/lib/code-signing/private-key.ts @@ -3,8 +3,8 @@ import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import lambda = require('@aws-cdk/aws-lambda'); import cdk = require('@aws-cdk/cdk'); -import fs = require('fs'); import path = require('path'); +import { hashFileOrDirectory } from '../util'; import { CertificateSigningRequest, DistinguishedName } from './certificate-signing-request'; export interface RsaPrivateKeySecretProps { @@ -63,17 +63,13 @@ export class RsaPrivateKeySecret extends cdk.Construct { props.deletionPolicy = props.deletionPolicy || cdk.DeletionPolicy.Retain; + const codeLocation = path.join(__dirname, 'private-key'); const customResource = new lambda.SingletonFunction(this, 'ResourceHandler', { uuid: '72FD327D-3813-4632-9340-28EC437AA486', description: 'Generates an RSA Private Key and stores it in AWS Secrets Manager', runtime: lambda.Runtime.Python36, handler: 'index.main', - code: new lambda.InlineCode( - fs.readFileSync(path.join(__dirname, 'private-key.py')) - .toString('utf8') - // Remove blank and comment-only lines, to shrink code length - .replace(/^[ \t]*(#[^\n]*)?\n/mg, '') - ), + code: new lambda.AssetCode(codeLocation), timeout: 300, }); @@ -81,10 +77,13 @@ export class RsaPrivateKeySecret extends cdk.Construct { service: 'secretsmanager', resource: 'secret', sep: ':', - resourceName: `${props.secretName}-*` + resourceName: `${props.secretName}-??????` }); customResource.addToRolePolicy(new iam.PolicyStatement() - .addActions('secretsmanager:CreateSecret', 'secretsmanager:DeleteSecret') + .addActions('secretsmanager:CreateSecret', + 'secretsmanager:DeleteSecret', + 'secretsmanager:ListSecretVersionIds', + 'secretsmanager:UpdateSecret') .addResource(this.secretArnLike)); if (props.secretEncryptionKey) { @@ -105,6 +104,7 @@ export class RsaPrivateKeySecret extends cdk.Construct { lambdaProvider: customResource, resourceType: 'Custom::RsaPrivateKeySecret', properties: { + resourceVersion: hashFileOrDirectory(codeLocation), description: props.description, keySize: props.keySize, secretName: props.secretName, @@ -132,8 +132,8 @@ export class RsaPrivateKeySecret extends cdk.Construct { privateKey.options.deletionPolicy = props.deletionPolicy; this.masterKey = props.secretEncryptionKey; - this.secretArn = privateKey.getAtt('ARN').toString(); - this.secretVersion = privateKey.getAtt('VersionId').toString(); + this.secretArn = privateKey.getAtt('SecretArn').toString(); + this.secretVersion = privateKey.getAtt('SecretVersionId').toString(); } /** diff --git a/lib/code-signing/private-key.py b/lib/code-signing/private-key/index.py similarity index 67% rename from lib/code-signing/private-key.py rename to lib/code-signing/private-key/index.py index b16dce3b..781fe311 100644 --- a/lib/code-signing/private-key.py +++ b/lib/code-signing/private-key/index.py @@ -9,7 +9,7 @@ # - Description (string): the description to attach to the secret. # # Outputs: -# - ARN (string): The AWS SecretsManager secret ARN +# - Arn (string): The AWS SecretsManager secret ARN # - VersionId (string): The AWS SecretsManager secret VersionId import logging as log @@ -22,10 +22,41 @@ def handle_event(event, aws_request_id): import boto3, shutil, subprocess, tempfile props = event['ResourceProperties'] + description = props.get('Description') + kmsKeyId = props.get('KmsKeyId') if event['RequestType'] == 'Update': - # Prohibit updates - you don't want to inadertently cause your private key to change... - raise Exception('X509 Private Key update requires replacement, a new resource must be created!') + old_props = event['OldResourceProperties'] + # Prohibit updates to KeySize or SecretName, as those would require re-creating the key... + if old_props['KeySize'] != props['KeySize']: + raise Exception(f'The KeySize property cannot be updated (attempting to change from {old_props["KeySize"]} to {props["KeySize"]})') + if old_props['SecretName'] != props['SecretName']: + raise Exception(f'The SecretName property cannot be updated (attempting to change from {old_props["SecretName"]} to {props["SecretName"]})') + + opts = { + 'SecretId': event['PhysicalResourceId'], + 'ClientRequestToken': aws_request_id + } + + if description is not None: opts['Description'] = description + if kmsKeyId: opts['KmsKeyId'] = kmsKeyId + + ret = boto3.client('secretsmanager').update_secret(**opts) + + # No new version was created - go fetch the current latest VersionId + if ret.get('VersionId') is None: + opts = dict(SecretId=ret['ARN']) + while True: + response = boto3.client('secretsmanager').list_secret_version_ids(**opts) + for version in response['Versions']: + if 'AWSCURRENT' in version['VersionStages']: + ret['VersionId'] = version['VersionId'] + break + if ret['VersionId'] is not None or response.get('NextToken') is None: + break + opts['NextToken'] = response['NextToken'] + + return {'SecretArn': ret['ARN'], 'SecretVersionId': ret['VersionId']} elif event['RequestType'] == 'Create': tmpdir = tempfile.mkdtemp() @@ -40,16 +71,16 @@ def handle_event(event, aws_request_id): 'SecretString': pkey.read() } - kmsKeyId = props.get('KmsKeyId') + if description is not None: opts['Description'] = description if kmsKeyId: opts['KmsKeyId'] = kmsKeyId ret = boto3.client('secretsmanager').create_secret(**opts) - return {'ARN': ret['ARN'], 'VersionId': ret['VersionId']} + return {'SecretArn': ret['ARN'], 'SecretVersionId': ret['VersionId']} elif event['RequestType'] == 'Delete': if event['PhysicalResourceId'].startswith('arn:'): # Only if the resource had been successfully created before boto3.client('secretsmanager').delete_secret(SecretId=event['PhysicalResourceId']) - return {'ARN': ''} + return {'SecretArn': '', 'SecretVersionId': ''} else: raise Exception('Unsupported RequestType: %s' % event['RequestType']) @@ -60,8 +91,9 @@ def main(event, context): try: log.info('Input event: %s', json.dumps(event)) attributes = handle_event(event, context.aws_request_id) - cfn_send(event, context, CFN_SUCCESS, attributes, attributes['ARN']) + cfn_send(event, context, CFN_SUCCESS, attributes, attributes['SecretArn']) except KeyError as e: + log.exception(e) cfn_send(event, context, CFN_FAILED, {}, reason="Invalid request: missing key %s" % str(e)) except Exception as e: log.exception(e) diff --git a/lib/credential-pair.ts b/lib/credential-pair.ts new file mode 100644 index 00000000..c9c39e9c --- /dev/null +++ b/lib/credential-pair.ts @@ -0,0 +1,35 @@ +/** + * A Credential Pair combines a secret element and a public element. The public + * element is stored in an SSM Parameter, while the secret element is stored in + * AWS Secrets Manager. + * + * For example, this can be: + * - A username and a password + * - A private key and a certificate + * - An OpenPGP Private key and its public part + */ +export interface ICredentialPair { + /** + * The ARN of the SSM parameter containing the public part of this credential + * pair. + */ + readonly parameterArn: string; + + /** + * The name of the SSM parameter containing the public part of this credential + * pair. + */ + readonly parameterName: string; + + /** + * The ARN of the AWS SecretsManager secret that holds the private part of + * this credential pair. + */ + readonly secretArn: string; + + /** + * The VersionId of the AWS SecretsManager secret that holds the private part + * of this credential pair. + */ + readonly secretVersionId: string; +} diff --git a/lib/pgp-secret.ts b/lib/pgp-secret.ts index 9262f854..7d824543 100644 --- a/lib/pgp-secret.ts +++ b/lib/pgp-secret.ts @@ -3,8 +3,9 @@ import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import lambda = require('@aws-cdk/aws-lambda'); import cdk = require('@aws-cdk/cdk'); -import fs = require('fs'); import path = require('path'); +import { ICredentialPair } from './credential-pair'; +import { hashFileOrDirectory } from './util'; interface PGPSecretProps { /** @@ -50,6 +51,11 @@ interface PGPSecretProps { * Bump this number to regenerate the key */ version: number; + + /** + * A description to attach to the AWS SecretsManager secret. + */ + description?: string; } /** @@ -59,27 +65,34 @@ interface PGPSecretProps { * * { "PrivateKey": "... ASCII repr of key...", "Passphrase": "passphrase of the key" } */ -export class PGPSecret extends cdk.Construct { - public readonly secretArn: string; +export class PGPSecret extends cdk.Construct implements ICredentialPair { + public readonly parameterArn: string; public readonly parameterName: string; + public readonly secretArn: string; + public readonly secretVersionId: string; constructor(parent: cdk.Construct, name: string, props: PGPSecretProps) { super(parent, name); const keyActions = ['kms:GenerateDataKey', 'kms:Encrypt', 'kms:Decrypt']; + const codeLocation = path.join(__dirname, 'pgp-secret'); const fn = new lambda.SingletonFunction(this, 'Lambda', { uuid: 'f25803d3-054b-44fc-985f-4860d7d6ee74', description: 'Generates an OpenPGP Key and stores the private key in Secrets Manager and the public key in an SSM Parameter', - code: new lambda.InlineCode(fs.readFileSync(path.join(__dirname, 'pgpresource.py'), { encoding: 'utf-8' })), + code: new lambda.AssetCode(codeLocation), handler: 'index.main', timeout: 300, runtime: lambda.Runtime.Python36, initialPolicy: [ - new iam.PolicyStatement().addActions( - 'secretsmanager:CreateSecret', 'secretsmanager:UpdateSecret', - 'secretsmanager:DeleteSecret', 'ssm:PutParameter', 'ssm:DeleteParameter' - ).addAllResources(), + new iam.PolicyStatement() + .addActions('secretsmanager:CreateSecret', + 'secretsmanager:ListSecretVersionIds', + 'secretsmanager:UpdateSecret', + 'secretsmanager:DeleteSecret', + 'ssm:PutParameter', + 'ssm:DeleteParameter') + .addAllResources(), new iam.PolicyStatement().addActions(...keyActions).addResource(props.encryptionKey.keyArn) ] }); @@ -91,6 +104,7 @@ export class PGPSecret extends cdk.Construct { const secret = new cfn.CustomResource(this, 'Resource', { lambdaProvider: fn, properties: { + resourceVersion: hashFileOrDirectory(codeLocation), identity: props.identity, email: props.email, expiry: props.expiry, @@ -98,10 +112,13 @@ export class PGPSecret extends cdk.Construct { secretName: props.secretName, keyArn: props.encryptionKey.keyArn, parameterName: props.pubKeyParameterName, - version: props.version + version: props.version, + description: props.description, }, }); - this.secretArn = secret.getAtt('ARN').toString(); - this.parameterName = props.pubKeyParameterName; + this.secretArn = secret.getAtt('SecretArn').toString(); + this.secretVersionId = secret.getAtt('SecretVersionId').toString(); + this.parameterName = secret.getAtt('ParameterName').toString(); + this.parameterArn = cdk.ArnUtils.fromComponents({ service: 'ssm', resource: 'parameter', resourceName: this.parameterName }); } } diff --git a/lib/pgp-secret/index.py b/lib/pgp-secret/index.py new file mode 100644 index 00000000..6f8b30f4 --- /dev/null +++ b/lib/pgp-secret/index.py @@ -0,0 +1,156 @@ +import logging as log +import json, os, sys + +CFN_SUCCESS = "SUCCESS" +CFN_FAILED = "FAILED" + +def handle_event(event, aws_request_id): + import random, string, tempfile, subprocess, boto3 + + props = event['ResourceProperties'] + description = props.get('Description') + new_key = event['RequestType'] == 'Create' + + if event['RequestType'] == 'Update': + old_props = event['OldResourceProperties'] + immutable_fields = ['Email', 'Expiry', 'Identity', 'KeySizeBits', 'ParameterName', 'SecretName', 'Version'] + for field in immutable_fields: + if props.get(field) != old_props.get(field): + log.info(f'New key required as {field} changed from {old_props.get(field)} to {props.get(field)}') + new_key = True + + if event['RequestType'] in ['Create', 'Update']: + if new_key: + passphrase = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16)) + + with tempfile.TemporaryDirectory() as tempdir_name: + os.environ['GNUPGHOME'] = tempdir_name + + with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8') as f: + f.write('Key-Type: RSA\n') + f.write('Key-Length: %s\n' % props['KeySizeBits']) + f.write('Name-Real: %s\n' % props['Identity']) + f.write('Name-Email: %s\n' % props['Email']) + f.write('Expire-Date: %s\n' % props['Expiry']) + f.write('Passphrase: %s\n' % passphrase) + f.write('%commit\n') + f.write('%echo done\n') + f.flush() + + print(f.name) + + subprocess.check_call(['gpg', + '--batch', '--gen-key', f.name], shell=False) + + keymaterial = subprocess.check_output(['gpg', + '--batch', '--yes', + '--passphrase', passphrase, '--export-secret-keys', '--armor'], shell=False).decode('utf-8') + + public_key = subprocess.check_output(['gpg', + '--batch', '--yes', + '--export', '--armor'], shell=False).decode('utf-8') + + call_args = dict( + ClientRequestToken=aws_request_id, + KmsKeyId=props.get('KeyArn'), + SecretString=json.dumps(dict(PrivateKey=keymaterial, Passphrase=passphrase))) + + if description is not None: call_args['Description'] = description + + if event['RequestType'] == 'Create': + ret = boto3.client('secretsmanager').create_secret( + Name=props['SecretName'], + **call_args) + else: + ret = boto3.client('secretsmanager').update_secret( + SecretId=event['PhysicalResourceId'], + **call_args) + + boto3.client('ssm').put_parameter( + Name=props['ParameterName'], + Description=f'Public part of OpenPGP key {ret["ARN"]}', + Value=public_key, + Type='String', + Overwrite=(event['RequestType'] == 'Update')) + else: + call_args = dict(SecretId=event['PhysicalResourceId'], + ClientRequestToken=aws_request_id, + KmsKeyId=props.get('KeyArn')) + + if description is not None: call_args['Description'] = description + + ret = boto3.client('secretsmanager').update_secret(**call_args) + + # No new version was created - go fetch the current latest VersionId + if ret.get('VersionId') is None: + opts = dict(SecretId=ret['ARN']) + while True: + response = boto3.client('secretsmanager').list_secret_version_ids(**opts) + for version in response['Versions']: + if 'AWSCURRENT' in version['VersionStages']: + ret['VersionId'] = version['VersionId'] + break + if ret['VersionId'] is not None or response.get('NextToken') is None: + break + opts['NextToken'] = response['NextToken'] + + return { + 'SecretArn': ret['ARN'], + 'SecretVersionId': ret['VersionId'], + 'ParameterName': props['ParameterName'] + } + + if event['RequestType'] == 'Delete': + if event['PhysicalResourceId'].startswith('arn:'): # Only if successfully created before + boto3.client('ssm').delete_parameter(Name=props['ParameterName']) + boto3.client('secretsmanager').delete_secret(SecretId=event['PhysicalResourceId']) + + return { 'SecretArn': '', 'SecretVersionId': '', 'ParameterName': '' } + + +def main(event, context): + log.getLogger().setLevel(log.INFO) + + try: + log.info('Input event: %s', json.dumps(event)) + attributes = handle_event(event, context.aws_request_id) + cfn_send(event, context, CFN_SUCCESS, attributes, attributes['SecretArn']) + except Exception as e: + log.exception(e) + cfn_send(event, context, CFN_FAILED, {}, event.get('PhysicalResourceId') or context.log_stream_name, reason=str(e)) + +#--------------------------------------------------------------------------------------------------- +# sends a response to cloudformation +def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None): + responseUrl = event['ResponseURL'] + log.info(responseUrl) + + responseBody = {} + responseBody['Status'] = responseStatus + responseBody['Reason'] = reason or ('See the details in CloudWatch Log Stream: ' + context.log_stream_name) + responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name + responseBody['StackId'] = event['StackId'] + responseBody['RequestId'] = event['RequestId'] + responseBody['LogicalResourceId'] = event['LogicalResourceId'] + responseBody['NoEcho'] = noEcho + responseBody['Data'] = responseData + + body = json.dumps(responseBody) + log.info("| response body:\n" + body) + + headers = { + 'content-type' : '', + 'content-length' : str(len(body)) + } + + try: + from botocore.vendored import requests + response = requests.put(responseUrl, data=body, headers=headers) + log.info("| status code: " + response.reason) + response.raise_for_status() + except Exception as e: + log.error("| unable to send response to CloudFormation") + raise e + +if __name__ == '__main__': + handle_event(json.load(sys.stdin), '7547bafb-5125-44c5-83e4-6eae56a52cce') diff --git a/lib/pgpresource.py b/lib/pgpresource.py deleted file mode 100644 index d3b53425..00000000 --- a/lib/pgpresource.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging as log -import json, os, sys - -def handle_event(event, aws_request_id): - import random, string, tempfile, subprocess, boto3 - - props = event['ResourceProperties'] - - if event['RequestType'] in ['Create', 'Update']: - passphrase = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16)) - - with tempfile.TemporaryDirectory() as tempdir_name: - os.environ['GNUPGHOME'] = tempdir_name - - with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8') as f: - f.write('Key-Type: RSA\n') - f.write('Key-Length: %s\n' % props['KeySizeBits']) - f.write('Name-Real: %s\n' % props['Identity']) - f.write('Name-Email: %s\n' % props['Email']) - f.write('Expire-Date: %s\n' % props['Expiry']) - f.write('Passphrase: %s\n' % passphrase) - f.write('%commit\n') - f.write('%echo done\n') - f.flush() - - print(f.name) - - subprocess.check_call(['gpg', - '--batch', '--gen-key', f.name], shell=False) - - keymaterial = subprocess.check_output(['gpg', - '--batch', '--yes', - '--passphrase', passphrase, '--export-secret-keys', '--armor'], shell=False).decode('utf-8') - - public_key = subprocess.check_output(['gpg', - '--batch', '--yes', - '--export', '--armor'], shell=False).decode('utf-8') - - call_args = dict( - ClientRequestToken=aws_request_id, - KmsKeyId=props['KeyArn'], - SecretString=json.dumps(dict(PrivateKey=keymaterial, Passphrase=passphrase))) - - if event['RequestType'] == 'Create': - ret = boto3.client('secretsmanager').create_secret( - Name=props['SecretName'], - **call_args) - else: - ret = boto3.client('secretsmanager').update_secret( - SecretId=event['PhysicalResourceId'], - **call_args) - - boto3.client('ssm').put_parameter( - Name=props['ParameterName'], - Description='Public part of signing key', - Value=public_key, - Type='String', - Overwrite=(event['RequestType'] == 'Update')) - - return ret - - if event['RequestType'] == 'Delete': - if event['PhysicalResourceId'].startswith('arn:'): # Only if successfully created before - boto3.client('ssm').delete_parameter(Name=props['ParameterName']) - boto3.client('secretsmanager').delete_secret(SecretId=event['PhysicalResourceId']) - return {'ARN': ''} - - return {'ARN': ''} - - -def main(event, context): - import cfnresponse - log.getLogger().setLevel(log.INFO) - - try: - log.info('Input event: %s', event) - - attributes = handle_event(event, context.aws_request_id) - - cfnresponse.send(event, context, cfnresponse.SUCCESS, attributes, attributes['ARN']) - except Exception as e: - log.exception(e) - # cfnresponse's error message is always "see CloudWatch" - cfnresponse.send(event, context, cfnresponse.FAILED, {}, context.log_stream_name) - - -if __name__ == '__main__': - handle_event(json.load(sys.stdin), '7547bafb-5125-44c5-83e4-6eae56a52cce') diff --git a/lib/publishing.ts b/lib/publishing.ts index ebedf623..7a8b19e1 100644 --- a/lib/publishing.ts +++ b/lib/publishing.ts @@ -156,8 +156,8 @@ export class PublishToNuGetProject extends cdk.Construct implements IPublisher { env.NUGET_SECRET_ID = { value: props.nugetApiKeySecret.secretArn }; if (props.codeSign) { - env.CODE_SIGNING_SECRET_ID = { value: props.codeSign.privateKeySecretArn }; - env.CODE_SIGNING_PARAMETER_NAME = { value: props.codeSign.certificateParameterName }; + env.CODE_SIGNING_SECRET_ID = { value: props.codeSign.secretArn }; + env.CODE_SIGNING_PARAMETER_NAME = { value: props.codeSign.parameterName }; } const shellable = new Shellable(this, 'Default', { @@ -329,4 +329,4 @@ export class PublishToGitHub extends cdk.Construct implements IPublisher { this.role = shellable.role; this.project = shellable.project; } -} \ No newline at end of file +} diff --git a/lib/util.ts b/lib/util.ts index a8c5a912..bb969ff7 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -1,3 +1,7 @@ +import crypto = require('crypto'); +import fs = require('fs'); +import path = require('path'); + /** * Determines the "RunOrder" property for the next action to be added to a stage. * @param index Index of new action @@ -11,3 +15,25 @@ export function determineRunOrder(index: number, concurrency?: number) { return Math.floor(index / concurrency) + 1; } + +/** + * Hashes the contents of a file or directory. If the argument is a directory, + * it is assumed not to contain symlinks that would result in a cyclic tree. + * + * @param fileOrDir the path to the file or directory that should be hashed. + * + * @returns a SHA256 hash, base-64 encoded. + */ +export function hashFileOrDirectory(fileOrDir: string): string { + const hash = crypto.createHash('SHA256'); + hash.update(path.basename(fileOrDir)).update('\0'); + const stat = fs.statSync(fileOrDir); + if (stat.isDirectory()) { + for (const item of fs.readdirSync(fileOrDir).sort()) { + hash.update(hashFileOrDirectory(path.join(fileOrDir, item))); + } + } else { + hash.update(fs.readFileSync(fileOrDir)); + } + return hash.digest('base64'); +} diff --git a/test/expected.json b/test/expected.json index 13b807b7..fc5bac49 100644 --- a/test/expected.json +++ b/test/expected.json @@ -1275,7 +1275,7 @@ Resources: Resource: Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 - - ARN + - SecretArn - Action: ssm:GetParameter Effect: Allow Resource: @@ -1354,7 +1354,7 @@ Resources: Value: Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 - - ARN + - SecretArn - Name: CODE_SIGNING_PARAMETER_NAME Type: PLAINTEXT Value: /delivlib-test/X509CodeSigningKey/Certificate @@ -1473,7 +1473,7 @@ Resources: Resource: Fn::GetAtt: - CodeSignSecretF6FDE552 - - ARN + - SecretArn - Action: kms:Decrypt Effect: Allow Resource: @@ -1652,7 +1652,7 @@ Resources: Resource: Fn::GetAtt: - CodeSignSecretF6FDE552 - - ARN + - SecretArn - Action: kms:Decrypt Effect: Allow Resource: @@ -2032,6 +2032,7 @@ Resources: Fn::GetAtt: - SingletonLambda72FD327D38134632934028EC437AA4865ADA6EFF - Arn + ResourceVersion: jHRqf4H63RNQK2j59bvZ7Suik/qdEzev8joYVXPy0N8= Description: The PEM-encoded private key of the x509 Code-Signing Certificate KeySize: 2048 SecretName: delivlib-test/X509CodeSigningKey/RSAPrivateKey @@ -2048,14 +2049,15 @@ Resources: Fn::GetAtt: - CreateCSR541F67826DCF49A78C5A67715ADD9E4C8F4169F6 - Arn + ResourceVersion: lg2A/uKz4Qjf3sXeKWLLZjjkhGST12DlLVRvq8Qn+aw= PrivateKeySecretId: Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 - - ARN + - SecretArn PrivateKeySecretVersion: Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 - - VersionId + - SecretVersionId DnCommonName: delivlib-test DnCountry: IL DnStateOrProvince: Ztate @@ -2084,11 +2086,11 @@ Resources: - - "A PEM-encoded Code-Signing Certificate (private key in " - Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 - - ARN + - SecretArn - " version " - Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 - - VersionId + - SecretVersionId - ) Name: /delivlib-test/X509CodeSigningKey/Certificate Metadata: @@ -2119,6 +2121,8 @@ Resources: - Action: - secretsmanager:CreateSecret - secretsmanager:DeleteSecret + - secretsmanager:ListSecretVersionIds + - secretsmanager:UpdateSecret Effect: Allow Resource: Fn::Join: @@ -2129,7 +2133,7 @@ Resources: - Ref: AWS::Region - ":" - Ref: AWS::AccountId - - :secret:delivlib-test/X509CodeSigningKey/RSAPrivateKey-* + - :secret:delivlib-test/X509CodeSigningKey/RSAPrivateKey-?????? Version: "2012-10-17" PolicyName: SingletonLambda72FD327D38134632934028EC437AA486ServiceRoleDefaultPolicy77701415 Roles: @@ -2140,81 +2144,21 @@ Resources: Type: AWS::Lambda::Function Properties: Code: - ZipFile: > - import logging as log - - import json, os, sys - - CFN_SUCCESS = "SUCCESS" - - CFN_FAILED = "FAILED" - - def handle_event(event, aws_request_id): - import boto3, shutil, subprocess, tempfile - props = event['ResourceProperties'] - if event['RequestType'] == 'Update': - raise Exception('X509 Private Key update requires replacement, a new resource must be created!') - elif event['RequestType'] == 'Create': - tmpdir = tempfile.mkdtemp() - with tempfile.TemporaryDirectory() as tmpdir: - pkey_file = os.path.join(tmpdir, 'private_key.pem') - subprocess.check_call(['openssl', 'genrsa', '-out', pkey_file, props['KeySize']], shell=False) - with open(pkey_file) as pkey: - opts = { - 'ClientRequestToken': aws_request_id, - 'Description': props.get('Description'), - 'Name': props['SecretName'], - 'SecretString': pkey.read() - } - kmsKeyId = props.get('KmsKeyId') - if kmsKeyId: opts['KmsKeyId'] = kmsKeyId - ret = boto3.client('secretsmanager').create_secret(**opts) - return {'ARN': ret['ARN'], 'VersionId': ret['VersionId']} - elif event['RequestType'] == 'Delete': - if event['PhysicalResourceId'].startswith('arn:'): # Only if the resource had been successfully created before - boto3.client('secretsmanager').delete_secret(SecretId=event['PhysicalResourceId']) - return {'ARN': ''} - else: - raise Exception('Unsupported RequestType: %s' % event['RequestType']) - def main(event, context): - log.getLogger().setLevel(log.INFO) - try: - log.info('Input event: %s', json.dumps(event)) - attributes = handle_event(event, context.aws_request_id) - cfn_send(event, context, CFN_SUCCESS, attributes, attributes['ARN']) - except KeyError as e: - cfn_send(event, context, CFN_FAILED, {}, reason="Invalid request: missing key %s" % str(e)) - except Exception as e: - log.exception(e) - cfn_send(event, context, CFN_FAILED, {}, reason=str(e)) - def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None): - responseUrl = event['ResponseURL'] - log.info(responseUrl) - responseBody = {} - responseBody['Status'] = responseStatus - responseBody['Reason'] = reason or ('See the details in CloudWatch Log Stream: ' + context.log_stream_name) - responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name - responseBody['StackId'] = event['StackId'] - responseBody['RequestId'] = event['RequestId'] - responseBody['LogicalResourceId'] = event['LogicalResourceId'] - responseBody['NoEcho'] = noEcho - responseBody['Data'] = responseData - body = json.dumps(responseBody) - log.info("| response body:\n" + body) - headers = { - 'content-type' : '', - 'content-length' : str(len(body)) - } - try: - from botocore.vendored import requests - response = requests.put(responseUrl, data=body, headers=headers) - log.info("| status code: " + response.reason) - response.raise_for_status() - except Exception as e: - log.error("| unable to send response to CloudFormation") - raise e - if __name__ == '__main__': - handle_event(json.load(sys.stdin), 'ec92d8a9-672c-4647-9d34-0d3159a2c692') + S3Bucket: + Ref: SingletonLambda72FD327D38134632934028EC437AA486CodeS3BucketE07F4BCB + S3Key: + Fn::Join: + - "" + - - Fn::Select: + - 0 + - Fn::Split: + - "||" + - Ref: SingletonLambda72FD327D38134632934028EC437AA486CodeS3VersionKeyF016F0F7 + - Fn::Select: + - 1 + - Fn::Split: + - "||" + - Ref: SingletonLambda72FD327D38134632934028EC437AA486CodeS3VersionKeyF016F0F7 Handler: index.main Role: Fn::GetAtt: @@ -2256,7 +2200,7 @@ Resources: Resource: Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 - - ARN + - SecretArn Version: "2012-10-17" PolicyName: CreateCSR541F67826DCF49A78C5A67715ADD9E4CServiceRoleDefaultPolicyC0800208 Roles: @@ -2373,6 +2317,7 @@ Resources: Fn::GetAtt: - SingletonLambdaf25803d3054b44fc985f4860d7d6ee746203BDE6 - Arn + ResourceVersion: +ai21G6fYSfCaSmWQGY88zMFxRirgnfInnT4SpH1IVo= Identity: aws-cdk-dev Email: aws-cdk-dev+delivlib@amazon.com Expiry: 4y @@ -2411,6 +2356,7 @@ Resources: Statement: - Action: - secretsmanager:CreateSecret + - secretsmanager:ListSecretVersionIds - secretsmanager:UpdateSecret - secretsmanager:DeleteSecret - ssm:PutParameter @@ -2436,97 +2382,21 @@ Resources: Type: AWS::Lambda::Function Properties: Code: - ZipFile: > - import logging as log - - import json, os, sys - - - def handle_event(event, aws_request_id): - import random, string, tempfile, subprocess, boto3 - - props = event['ResourceProperties'] - - if event['RequestType'] in ['Create', 'Update']: - passphrase = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16)) - - with tempfile.TemporaryDirectory() as tempdir_name: - os.environ['GNUPGHOME'] = tempdir_name - - with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8') as f: - f.write('Key-Type: RSA\n') - f.write('Key-Length: %s\n' % props['KeySizeBits']) - f.write('Name-Real: %s\n' % props['Identity']) - f.write('Name-Email: %s\n' % props['Email']) - f.write('Expire-Date: %s\n' % props['Expiry']) - f.write('Passphrase: %s\n' % passphrase) - f.write('%commit\n') - f.write('%echo done\n') - f.flush() - - print(f.name) - - subprocess.check_call(['gpg', - '--batch', '--gen-key', f.name], shell=False) - - keymaterial = subprocess.check_output(['gpg', - '--batch', '--yes', - '--passphrase', passphrase, '--export-secret-keys', '--armor'], shell=False).decode('utf-8') - - public_key = subprocess.check_output(['gpg', - '--batch', '--yes', - '--export', '--armor'], shell=False).decode('utf-8') - - call_args = dict( - ClientRequestToken=aws_request_id, - KmsKeyId=props['KeyArn'], - SecretString=json.dumps(dict(PrivateKey=keymaterial, Passphrase=passphrase))) - - if event['RequestType'] == 'Create': - ret = boto3.client('secretsmanager').create_secret( - Name=props['SecretName'], - **call_args) - else: - ret = boto3.client('secretsmanager').update_secret( - SecretId=event['PhysicalResourceId'], - **call_args) - - boto3.client('ssm').put_parameter( - Name=props['ParameterName'], - Description='Public part of signing key', - Value=public_key, - Type='String', - Overwrite=(event['RequestType'] == 'Update')) - - return ret - - if event['RequestType'] == 'Delete': - if event['PhysicalResourceId'].startswith('arn:'): # Only if successfully created before - boto3.client('ssm').delete_parameter(Name=props['ParameterName']) - boto3.client('secretsmanager').delete_secret(SecretId=event['PhysicalResourceId']) - return {'ARN': ''} - - return {'ARN': ''} - - - def main(event, context): - import cfnresponse - log.getLogger().setLevel(log.INFO) - - try: - log.info('Input event: %s', event) - - attributes = handle_event(event, context.aws_request_id) - - cfnresponse.send(event, context, cfnresponse.SUCCESS, attributes, attributes['ARN']) - except Exception as e: - log.exception(e) - # cfnresponse's error message is always "see CloudWatch" - cfnresponse.send(event, context, cfnresponse.FAILED, {}, context.log_stream_name) - - - if __name__ == '__main__': - handle_event(json.load(sys.stdin), '7547bafb-5125-44c5-83e4-6eae56a52cce') + S3Bucket: + Ref: SingletonLambdaf25803d3054b44fc985f4860d7d6ee74CodeS3BucketAC499781 + S3Key: + Fn::Join: + - "" + - - Fn::Select: + - 0 + - Fn::Split: + - "||" + - Ref: SingletonLambdaf25803d3054b44fc985f4860d7d6ee74CodeS3VersionKey10D2DDF5 + - Fn::Select: + - 1 + - Fn::Split: + - "||" + - Ref: SingletonLambdaf25803d3054b44fc985f4860d7d6ee74CodeS3VersionKey10D2DDF5 Handler: index.main Role: Fn::GetAtt: @@ -2618,6 +2488,14 @@ Parameters: Type: String Description: S3 key for asset version "delivlib-test/AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62c/Code" + SingletonLambda72FD327D38134632934028EC437AA486CodeS3BucketE07F4BCB: + Type: String + Description: S3 bucket for asset + "delivlib-test/SingletonLambda72FD327D38134632934028EC437AA486/Code" + SingletonLambda72FD327D38134632934028EC437AA486CodeS3VersionKeyF016F0F7: + Type: String + Description: S3 key for asset version + "delivlib-test/SingletonLambda72FD327D38134632934028EC437AA486/Code" CreateCSR541F67826DCF49A78C5A67715ADD9E4CCodeS3Bucket45923B19: Type: String Description: S3 bucket for asset @@ -2626,6 +2504,14 @@ Parameters: Type: String Description: S3 key for asset version "delivlib-test/CreateCSR541F67826DCF49A78C5A67715ADD9E4C/Code" + SingletonLambdaf25803d3054b44fc985f4860d7d6ee74CodeS3BucketAC499781: + Type: String + Description: S3 bucket for asset + "delivlib-test/SingletonLambdaf25803d3054b44fc985f4860d7d6ee74/Code" + SingletonLambdaf25803d3054b44fc985f4860d7d6ee74CodeS3VersionKey10D2DDF5: + Type: String + Description: S3 key for asset version + "delivlib-test/SingletonLambdaf25803d3054b44fc985f4860d7d6ee74/Code" Outputs: X509CodeSigningKeyCSR5137C5A3: Description: A PEM-encoded Certificate Signing Request for a Code-Signing Certificate diff --git a/test/pgp-secret.test.ts b/test/pgp-secret.test.ts index ab9bd847..f2f9aa0c 100644 --- a/test/pgp-secret.test.ts +++ b/test/pgp-secret.test.ts @@ -50,5 +50,5 @@ test('correctly forwards parameter name', () => { }); // THEN - expect(secret.parameterName).toBe(parameterName); + expect(cdk.resolve(secret.parameterName)).toEqual({ "Fn::GetAtt": ["SecretA720EF05", "ParameterName"] }); }); From 66c239c53bfb7195f4bddd74500ed138097e7c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Thu, 20 Dec 2018 15:00:20 +0100 Subject: [PATCH 2/9] Re-implement custom resources in TS/JavaScript --- build-custom-resource-handlers.sh | 24 +++ .../src/_cloud-formation.ts | 93 +++++++++++ custom-resource-handlers/src/_exec.ts | 21 +++ custom-resource-handlers/src/_lambda.ts | 90 ++++++++++ custom-resource-handlers/src/_rmrf.ts | 15 ++ .../src/_secrets-manager.ts | 17 ++ .../src/certificate-signing-request.ts | 106 ++++++++++++ custom-resource-handlers/src/pgp-secret.ts | 136 +++++++++++++++ custom-resource-handlers/src/private-key.ts | 95 +++++++++++ .../certificate-signing-request.ts | 4 +- .../certificate-signing-request/index.py | 140 ---------------- lib/code-signing/private-key.ts | 4 +- lib/code-signing/private-key/index.py | 136 --------------- lib/pgp-secret.ts | 4 +- lib/pgp-secret/index.py | 156 ------------------ package.json | 3 +- tsconfig.json | 3 +- 17 files changed, 607 insertions(+), 440 deletions(-) create mode 100644 build-custom-resource-handlers.sh create mode 100644 custom-resource-handlers/src/_cloud-formation.ts create mode 100644 custom-resource-handlers/src/_exec.ts create mode 100644 custom-resource-handlers/src/_lambda.ts create mode 100644 custom-resource-handlers/src/_rmrf.ts create mode 100644 custom-resource-handlers/src/_secrets-manager.ts create mode 100644 custom-resource-handlers/src/certificate-signing-request.ts create mode 100644 custom-resource-handlers/src/pgp-secret.ts create mode 100644 custom-resource-handlers/src/private-key.ts delete mode 100644 lib/code-signing/certificate-signing-request/index.py delete mode 100644 lib/code-signing/private-key/index.py delete mode 100644 lib/pgp-secret/index.py diff --git a/build-custom-resource-handlers.sh b/build-custom-resource-handlers.sh new file mode 100644 index 00000000..69a92851 --- /dev/null +++ b/build-custom-resource-handlers.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +compile="tsc --alwaysStrict + --lib ES2017 + --module CommonJS + --moduleResolution Node + --noFallthroughCasesInSwitch + --noImplicitAny + --noImplicitReturns + --noImplicitThis + --noUnusedLocals + --noUnusedParameters + --removeComments + --strict + --target ES2017 + --types node" + +for handler in pgp-secret private-key certificate-signing-request +do + echo "Building CustomResource handler ${handler}" + ${compile} --outDir "./custom-resource-handlers/bin/${handler}" "./custom-resource-handlers/src/${handler}.ts" ./custom-resource-handlers/src/_*.ts + mv "./custom-resource-handlers/bin/${handler}/${handler}.js" "./custom-resource-handlers/bin/${handler}/index.js" +done diff --git a/custom-resource-handlers/src/_cloud-formation.ts b/custom-resource-handlers/src/_cloud-formation.ts new file mode 100644 index 00000000..ce3a8fc5 --- /dev/null +++ b/custom-resource-handlers/src/_cloud-formation.ts @@ -0,0 +1,93 @@ +import https = require('https'); +import url = require('url'); + +/** + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-responses.html + */ +export function sendResponse(event: Event, + status: Status, + physicalResourceId: string, + data: { [name: string]: string | undefined }, + reason?: string) { + const responseBody = JSON.stringify({ + Data: data, + LogicalResourceId: event.LogicalResourceId, + PhysicalResourceId: physicalResourceId, + Reason: reason, + RequestId: event.RequestId, + StackId: event.StackId, + Status: status, + }, null, 2); + + // tslint:disable-next-line:no-console + console.log(`Response body: ${responseBody}`); + + const parsedUrl = url.parse(event.ResponseURL); + const options = { + headers: { + 'content-length': responseBody.length, + 'content-type': '', + }, + hostname: parsedUrl.hostname, + method: 'PUT', + path: parsedUrl.path, + port: parsedUrl.port || 443, + }; + + return new Promise((ok, ko) => { + // tslint:disable-next-line:no-console + console.log('Sending response...'); + + const req = https.request(options, resp => { + if (resp.statusCode === 200) { + return ok(); + } + ko(`Unexpected error sending resopnse to CloudFormation: HTTP ${resp.statusCode} (${resp.statusMessage})`); + }); + + req.on('error', ko); + req.write(responseBody); + + req.end(); + }); +} + +export enum Status { + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', +} + +export enum RequestType { + CREATE = 'Create', + UPDATE = 'Update', + DELETE = 'Delete', +} + +/** @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html */ +export type Event = CreateEvent | UpdateEvent | DeleteEvent; + +export interface CloudFormationEventBase { + readonly RequestType: RequestType; + readonly ResponseURL: string; + readonly StackId: string; + readonly RequestId: string; + readonly ResourceType: string; + readonly LogicalResourceId: string; + readonly ResourceProperties: { [name: string]: any }; +} + +export interface CreateEvent extends CloudFormationEventBase { + readonly RequestType: RequestType.CREATE; + readonly PhysicalResourceId: undefined; +} + +export interface UpdateEvent extends CloudFormationEventBase { + readonly RequestType: RequestType.UPDATE; + readonly PhysicalResourceId: string; + readonly OldResourceProperties: { [name: string]: any }; +} + +export interface DeleteEvent extends CloudFormationEventBase { + readonly RequestType: RequestType.DELETE; + readonly PhysicalResourceId: string; +} diff --git a/custom-resource-handlers/src/_exec.ts b/custom-resource-handlers/src/_exec.ts new file mode 100644 index 00000000..c9eedd50 --- /dev/null +++ b/custom-resource-handlers/src/_exec.ts @@ -0,0 +1,21 @@ +import childProcess = require('child_process'); + +export = function _exec(command: string): Promise { + return new Promise((ok, ko) => { + const child = childProcess.spawn(command, { shell: false, stdio: ['ignore', 'pipe', 'inherit'] }); + const chunks = new Array(); + + child.stdout.on('data', (chunk) => { + process.stdout.write(chunk); + chunks.push(chunk); + }); + + child.once('error', ko); + child.once('exit', (code, signal) => { + if (code === 0) { + return ok(Buffer.concat(chunks).toString('utf8')); + } + ko(signal != null ? `Killed by ${signal}` : `Returned ${code}`); + }); + }); +}; diff --git a/custom-resource-handlers/src/_lambda.ts b/custom-resource-handlers/src/_lambda.ts new file mode 100644 index 00000000..b820249e --- /dev/null +++ b/custom-resource-handlers/src/_lambda.ts @@ -0,0 +1,90 @@ +/** + * @see https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html + */ +export interface Context { + /** + * The name of the Lambda function + */ + readonly functionName: string; + + /** + * The version of the function + */ + readonly functionVersion: string; + + /** + * The Amazon Resource Name (ARN) used to invoke the function. Indicates if the invoker specified a version number + * or alias. + */ + readonly invokedFunctionArn: string; + + /** + * The amount of memory configured on the function. + */ + readonly memoryLimitInMB: number; + + /** + * The identifier of the invocation request? + */ + readonly awsRequestId: string; + + /** + * The log group for the function. + */ + readonly logGroupName: string; + + /** + * The log stream for the function instance. + */ + readonly logStreamName: string; + + /** + * Set to false to send the response right away when the callback executes, instead of waiting for the Node.js event + * loop to be empty. If false, any outstanding events will continue to run during the next invocation. + */ + callbackWaitsForEmptyEventLoop: boolean; + + /** + * For mobile apps, information about the Amazon Cognito identity that authorized the request. + */ + identity?: { + /** + * The authenticated Amazon Cognito identity. + */ + cognitoIdentityId: string; + + /** + * The Amazon Cognito identity pool that authorized the invocation. + */ + cognitoIdentityPoolId: string; + }; + + /** + * For mobile apps, client context provided to the Lambda invoker by the client application. + */ + clientContext?: { + client: { + installation_id: string; + app_title: string; + app_version_name: string; + app_version_code: string; + app_package_name: string; + }; + env: { + platform_version: string; + platform: string; + make: string; + model: string; + locale: string; + }; + /** + * Custom values set by the mobile application. + */ + Custom: { [name: string]: any }; + } + + /** + * Returns the number of milliseconds left before the execution times out. + */ + getRemainingTimeInMillis(): number; +} diff --git a/custom-resource-handlers/src/_rmrf.ts b/custom-resource-handlers/src/_rmrf.ts new file mode 100644 index 00000000..3af40d24 --- /dev/null +++ b/custom-resource-handlers/src/_rmrf.ts @@ -0,0 +1,15 @@ +import fs = require('fs'); +import path = require('path'); +import util = require('util'); + +export = async function _rmrf(filePath: string): Promise { + const stat = await util.promisify(fs.stat)(filePath); + if (stat.isDirectory()) { + for (const child of await util.promisify(fs.readdir)(filePath)) { + await _rmrf(path.join(filePath, child)); + } + await util.promisify(fs.rmdir)(filePath); + } else { + await util.promisify(fs.unlink)(filePath); + } +} diff --git a/custom-resource-handlers/src/_secrets-manager.ts b/custom-resource-handlers/src/_secrets-manager.ts new file mode 100644 index 00000000..a5516262 --- /dev/null +++ b/custom-resource-handlers/src/_secrets-manager.ts @@ -0,0 +1,17 @@ +import aws = require('aws-sdk'); + +export async function resolveCurrentVersionId(secretId: string, + client: aws.SecretsManager = new aws.SecretsManager()): Promise { + const request: aws.SecretsManager.ListSecretVersionIdsRequest = { SecretId: secretId }; + do { + const response = await client.listSecretVersionIds(request).promise(); + request.NextToken = response.NextToken; + if (!response.Versions) { continue; } + for (const version of response.Versions) { + if (version.VersionId && version.VersionStages && version.VersionStages.indexOf('AWSCURRENT') !== -1) { + return version.VersionId; + } + } + } while (request.NextToken != null); + throw new Error(`Unable to determine the current VersionId of ${secretId}`); +} diff --git a/custom-resource-handlers/src/certificate-signing-request.ts b/custom-resource-handlers/src/certificate-signing-request.ts new file mode 100644 index 00000000..80c2b290 --- /dev/null +++ b/custom-resource-handlers/src/certificate-signing-request.ts @@ -0,0 +1,106 @@ +import aws = require('aws-sdk'); +import fs = require('fs'); +import os = require('os'); +import path = require('path'); +import util = require('util'); + +import cfn = require('./_cloud-formation'); +import _exec = require('./_exec'); +import lambda = require('./_lambda'); +import _rmrf = require('./_rmrf'); + +const secretsManager = new aws.SecretsManager(); + +export async function main(event: cfn.Event, context: lambda.Context): Promise { + try { + // tslint:disable-next-line:no-console + console.log(`Input event: ${JSON.stringify(event)}`); + const attributes = await handleEvent(event, context); + await cfn.sendResponse(event, + cfn.Status.SUCCESS, + event.LogicalResourceId, + attributes); + } catch (e) { + // tslint:disable-next-line:no-console + console.error(e); + await cfn.sendResponse(event, + cfn.Status.FAILED, + event.LogicalResourceId, + { SecretArn: '' }, + e.message); + } +} + +interface ResourceAttributes { + CSR: string; + SelfSignedCertificate: string; + + [name: string]: string | undefined; +} + +async function handleEvent(event: cfn.Event, _context: lambda.Context): Promise { + switch (event.RequestType) { + case cfn.RequestType.CREATE: + case cfn.RequestType.UPDATE: + return _createSelfSignedCertificate(event); + case cfn.RequestType.DELETE: + // Nothing to do - this is not a "Physical" resource + return { CSR: '', SelfSignedCertificate: '' }; + } +} + +async function _createSelfSignedCertificate(event: cfn.Event): Promise { + const tempDir = await util.promisify(fs.mkdtemp)(os.tmpdir()); + try { + const configFile = await _makeCsrConfig(event, tempDir); + const pkeyFile = await _retrievePrivateKey(event, tempDir); + const csrFile = path.join(tempDir, 'csr.pem'); + await _exec(`openssl req -config ${configFile} -key ${pkeyFile} -out ${csrFile} -new`); + const certFile = path.join(tempDir, 'cert.pem'); + await _exec(`openssl x509 -in ${csrFile} -out ${certFile} -req -signkey ${pkeyFile} -days 365`); + return { + CSR: await util.promisify(fs.readFile)(csrFile, { encoding: 'utf8' }), + SelfSignedCertificate: await util.promisify(fs.readFile)(certFile, { encoding: 'utf8' }), + }; + } finally { + await _rmrf(tempDir); + } +} + +async function _makeCsrConfig(event: cfn.Event, dir: string): Promise { + const file = path.join(dir, 'csr.config'); + await util.promisify(fs.writeFile)(file, [ + '[ req ]', + 'default_md = sha256', + 'distinguished_name = dn', + 'prompt = no', + 'req_extensions = extensions', + 'string_mask = utf8only', + 'utf8 = yes', + '', + '[ dn ]', + `CN = ${event.ResourceProperties.DnCommonName}`, + `C = ${event.ResourceProperties.DnCountry}`, + `ST = ${event.ResourceProperties.DnStateOrProvince}`, + `L = ${event.ResourceProperties.DnLocality}`, + `O = ${event.ResourceProperties.DnOrganizationName}`, + `OU = ${event.ResourceProperties.DnOrganizationalUnitName}`, + `emailAddress = ${event.ResourceProperties.DnEmailAddress}`, + '', + '[ extensions ]', + `extendedKeyUsage = ${event.ResourceProperties.ExtendedKeyUsage}`, + `keyUsage = ${event.ResourceProperties.KeyUsage}`, + 'subjectKeyIdentifier = hash', + ].join('\n')); + return file; +} + +async function _retrievePrivateKey(event: cfn.Event, dir: string): Promise { + const file = path.join(dir, 'private_key.pem'); + const secret = await secretsManager.getSecretValue({ + SecretId: event.ResourceProperties.PrivateKeySecretId, + VersionId: event.ResourceProperties.PrivateKeySecretVersionId, + }).promise(); + await util.promisify(fs.writeFile)(file, secret.SecretString!); + return file; +} diff --git a/custom-resource-handlers/src/pgp-secret.ts b/custom-resource-handlers/src/pgp-secret.ts new file mode 100644 index 00000000..45f78462 --- /dev/null +++ b/custom-resource-handlers/src/pgp-secret.ts @@ -0,0 +1,136 @@ +import aws = require('aws-sdk'); +import crypto = require('crypto'); +import fs = require('fs'); +import os = require('os'); +import path = require('path'); +import util = require('util'); + +import cfn = require('./_cloud-formation'); +import _exec = require('./_exec'); +import lambda = require('./_lambda'); +import _rmrf = require('./_rmrf'); +import { resolveCurrentVersionId } from './_secrets-manager'; + +const secretsManager = new aws.SecretsManager(); +const ssm = new aws.SSM(); + +export async function main(event: cfn.Event, context: lambda.Context): Promise { + try { + // tslint:disable-next-line:no-console + console.log(`Input event: ${JSON.stringify(event)}`); + const attributes = await handleEvent(event, context); + await cfn.sendResponse(event, + cfn.Status.SUCCESS, + attributes.SecretArn, + attributes); + } catch (e) { + // tslint:disable-next-line:no-console + console.error(e); + await cfn.sendResponse(event, + cfn.Status.FAILED, + event.PhysicalResourceId || context.logStreamName, + { SecretArn: '' }, + e.message); + } +} + +interface ResourceAttributes { + SecretArn: string; + SecretVersionId?: string; + ParameterName?: string; + + [name: string]: string | undefined; +} + +async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { + const props = event.ResourceProperties; + let newKey = event.RequestType === cfn.RequestType.CREATE; + + if (event.RequestType === 'Update') { + const oldProps = event.OldResourceProperties; + const immutableFields = ['Email', 'Expiry', 'Identity', 'KeySizeBits', 'ParameterName', 'SecretName', 'Version']; + for (const key of immutableFields) { + if (props[key] !== oldProps[key]) { + // tslint:disable-next-line:no-console + console.log(`New key required: ${key} changed from ${oldProps[key]} to ${props[key]}`); + newKey = true; + } + } + } + + switch (event.RequestType) { + case cfn.RequestType.CREATE: + case cfn.RequestType.UPDATE: + return newKey + ? await _createNewKey(event, context) + : await _updateExistingKey(event as cfn.UpdateEvent, context); + case cfn.RequestType.DELETE: + return await _deleteSecret(event); + } +} + +async function _createNewKey(event: cfn.CreateEvent | cfn.UpdateEvent, context: lambda.Context): Promise { + const passPhrase = crypto.randomBytes(32).toString('base64'); + const tempDir = await util.promisify(fs.mkdtemp)(os.tmpdir()); + try { + process.env.GNUPGHOME = tempDir; + + const keyConfig = path.join(tempDir, 'key.config'); + await util.promisify(fs.writeFile)(keyConfig, [ + 'Key-Type: RSA', + `Key-Length: ${event.ResourceProperties.KeySizeBits}`, + `Name-Real: ${event.ResourceProperties.Identity}`, + `Name-Email: ${event.ResourceProperties.Email}`, + `Expire-Date: ${event.ResourceProperties.Expiry}`, + `Passphrase: ${passPhrase}`, + '%commit', + '%echo done', + ].join('\n'), { encoding: 'utf8' }); + + await _exec(`gpg --batch --gen-key ${keyConfig}`); + const keyMaterial = await _exec('gpg --batch --yes --export-secret-keys --armor'); + const publicKey = await _exec('gpg --batch --yes --export --armor'); + + const secretOpts = { + ClientRequestToken: context.awsRequestId, + Description: event.ResourceProperties.Description, + KmsKeyId: event.ResourceProperties.KeyArn, + SecretString: keyMaterial, + }; + const secret = event.RequestType === cfn.RequestType.CREATE + ? await secretsManager.createSecret({ ...secretOpts, Name: event.ResourceProperties.SecretName }).promise() + : await secretsManager.updateSecret({ ...secretOpts, SecretId: event.PhysicalResourceId }).promise(); + await ssm.putParameter({ + Description: `Public part of OpenPGP key ${secret.ARN} (version ${secret.VersionId})`, + Name: event.ResourceProperties.ParameterName, + Overwrite: event.RequestType === 'Update', + Type: 'String', + Value: publicKey, + }).promise(); + + return { SecretArn: secret.ARN!, SecretVersionId: secret.VersionId!, ParameterName: event.ResourceProperties.ParameterName }; + } finally { + await _rmrf(tempDir); + } +} + +async function _deleteSecret(event: cfn.DeleteEvent): Promise { + if (!event.PhysicalResourceId.startsWith('arn:')) { return { SecretArn: '' }; } + await ssm.deleteParameter({ Name: event.ResourceProperties.ParameterName }).promise(); + await secretsManager.deleteSecret({ SecretId: event.PhysicalResourceId }).promise(); + return { SecretArn: '' }; +} + +async function _updateExistingKey(event: cfn.UpdateEvent, context: lambda.Context): Promise { + const result = await secretsManager.updateSecret({ + ClientRequestToken: context.awsRequestId, + Description: event.ResourceProperties.Description, + KmsKeyId: event.ResourceProperties.KeyArn, + SecretId: event.PhysicalResourceId, + }).promise(); + return { + SecretArn: result.ARN!, + SecretVersionId: result.VersionId || await resolveCurrentVersionId(result.ARN!, secretsManager), + ParameterName: event.ResourceProperties.ParameterName + }; +} diff --git a/custom-resource-handlers/src/private-key.ts b/custom-resource-handlers/src/private-key.ts new file mode 100644 index 00000000..85027ca9 --- /dev/null +++ b/custom-resource-handlers/src/private-key.ts @@ -0,0 +1,95 @@ +import aws = require('aws-sdk'); +import fs = require('fs'); +import os = require('os'); +import path = require('path'); +import util = require('util'); + +import cfn = require('./_cloud-formation'); +import _exec = require('./_exec'); +import lambda = require('./_lambda'); +import _rmrf = require('./_rmrf'); +import { resolveCurrentVersionId } from './_secrets-manager'; + +const secretsManager = new aws.SecretsManager(); + +export async function main(event: cfn.Event, context: lambda.Context): Promise { + try { + // tslint:disable-next-line:no-console + console.log(`Input event: ${JSON.stringify(event)}`); + const attributes = await handleEvent(event, context); + await cfn.sendResponse(event, + cfn.Status.SUCCESS, + attributes.SecretArn, + attributes); + } catch (e) { + // tslint:disable-next-line:no-console + console.error(e); + await cfn.sendResponse(event, + cfn.Status.FAILED, + event.PhysicalResourceId || context.logStreamName, + { SecretArn: '' }, + e.message); + } +} + +async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { + switch (event.RequestType) { + case cfn.RequestType.CREATE: + return await _createSecret(event, context); + case cfn.RequestType.UPDATE: + return await _updateSecret(event, context); + case cfn.RequestType.DELETE: + return await _deleteSecret(event); + } +} + +interface ResourceAttributes { + SecretArn: string; + SecretVersionId: string; + + [name: string]: string | undefined; +} + +async function _createSecret(event: cfn.CreateEvent, context: lambda.Context): Promise { + const tmpDir = await util.promisify(fs.mkdtemp)(os.tmpdir()); + try { + const pkeyFile = path.join(tmpDir, 'private_key.pem'); + _exec(`openssl genrsa -out ${pkeyFile} ${event.ResourceProperties.KeySize}`); + const result = await secretsManager.createSecret({ + ClientRequestToken: context.awsRequestId, + Description: event.ResourceProperties.Description, + KmsKeyId: event.ResourceProperties.KmsKeyId, + Name: event.ResourceProperties.SecretName, + SecretString: await util.promisify(fs.readFile)(pkeyFile, { encoding: 'utf8' }), + }).promise(); + return { SecretArn: result.ARN!, SecretVersionId: result.VersionId! }; + } finally { + _rmrf(tmpDir); + } +} + +async function _deleteSecret(event: cfn.DeleteEvent): Promise { + if (event.PhysicalResourceId.startsWith('arn:')) { + await secretsManager.deleteSecret({ + SecretId: event.PhysicalResourceId, + }).promise(); + } + return { SecretArn: '', SecretVersionId: '' }; +} + +async function _updateSecret(event: cfn.UpdateEvent, context: lambda.Context): Promise { + const props = event.ResourceProperties; + const oldProps = event.OldResourceProperties; + for (const key of ['KeySize', 'SecretName']) { + if (oldProps[key] !== props[key]) { + throw new Error(`The ${key} property cannot be updated, but it was changed from ${oldProps[key]} to ${props[key]}`); + } + } + const result = await secretsManager.updateSecret({ + ClientRequestToken: context.awsRequestId, + Description: props.Description, + KmsKeyId: props.KmsKeyId, + SecretId: event.PhysicalResourceId, + }).promise(); + return { SecretArn: result.ARN!, SecretVersionId: result.VersionId || await resolveCurrentVersionId(result.ARN!, secretsManager) }; +} diff --git a/lib/code-signing/certificate-signing-request.ts b/lib/code-signing/certificate-signing-request.ts index 9911241f..b062c19c 100644 --- a/lib/code-signing/certificate-signing-request.ts +++ b/lib/code-signing/certificate-signing-request.ts @@ -47,12 +47,12 @@ export class CertificateSigningRequest extends cdk.Construct { constructor(parent: cdk.Construct, id: string, props: CertificateSigningRequestProps) { super(parent, id); - const codeLocation = path.join(__dirname, 'certificate-signing-request'); + const codeLocation = path.resolve(__dirname, '..', '..', 'custom-resource-handlers', 'bin', 'certificate-signing-request'); const customResource = new lambda.SingletonFunction(this, 'ResourceHandler', { uuid: '541F6782-6DCF-49A7-8C5A-67715ADD9E4C', lambdaPurpose: 'CreateCSR', description: 'Creates a Certificate Signing Request document for an x509 certificate', - runtime: lambda.Runtime.Python36, + runtime: lambda.Runtime.NodeJS810, handler: 'index.main', code: new lambda.AssetCode(codeLocation), timeout: 300, diff --git a/lib/code-signing/certificate-signing-request/index.py b/lib/code-signing/certificate-signing-request/index.py deleted file mode 100644 index b2666851..00000000 --- a/lib/code-signing/certificate-signing-request/index.py +++ /dev/null @@ -1,140 +0,0 @@ -# Uses the openssl CLI to generate a Certificate Signing Request (CSR) document using a pre-existing Private Key that -# is stored in an AWS SecretsManager secret. -# -# Inputs (required): -# - PrivateKeySecretId (string): The ID/ARN of the AWS SecretsManager secret ID holding the Private Key -# - DnCommonName (string): The Common Name to register in the CSR (e.g: www.acme.com) -# - DnCountry (string): The Country to register in the CSR (ISO 2-letter code, e.g: US) -# - DnStateOrProvince (string): The State or Province to register in the CSR (e.g: Washington) -# - DnLocality (string): The Locality to register in the CSR (e.g: Seattle) -# - DnOrganizationName (string): The Organization to register in the CSR (e.g: ACME, Inc.) -# - DnOrganizationalUnitName (string): The Org. Unit name to register in the CSR (e.g: Security Dept.) -# - DnEmailAddress (string): The email address to register in the CSR (e.g: admin@acme.com) -# - KeyUsage (string): The key usage to request in the CSR (e.g: critical,digitalSignature) -# - ExtendedKeyUsage (string): The extended key usage to request in the CSR (e.g: critical,codeSigning) -# -# Outputs: -# - CSR (string): The Certificate Signing Request document, PEM-encoded. - -import logging as log -import json, os, sys - -CFN_SUCCESS = "SUCCESS" -CFN_FAILED = "FAILED" - -def handle_event(event, aws_request_id): - import boto3, subprocess, tempfile - - props = event['ResourceProperties'] - - if event['RequestType'] in ['Create', 'Update']: - with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8') as config: - # Creating a CSR Config file - config.write('[ req ]\n') - config.write('default_md = sha256\n') - config.write('distinguished_name = dn\n') - config.write('prompt = no\n') - config.write('req_extensions = extensions\n') - config.write('string_mask = utf8only\n') - config.write('utf8 = yes\n') - config.write('\n') - config.write('[ dn ]\n') - config.write('CN = %s\n' % props['DnCommonName']) - config.write('C = %s\n' % props['DnCountry']) - config.write('ST = %s\n' % props['DnStateOrProvince']) - config.write('L = %s\n' % props['DnLocality']) - config.write('O = %s\n' % props['DnOrganizationName']) - config.write('OU = %s\n' % props['DnOrganizationalUnitName']) - config.write('emailAddress = %s\n' % props['DnEmailAddress']) - config.write('\n') - config.write('[ extensions ]\n') - config.write('extendedKeyUsage = %s\n' % props['ExtendedKeyUsage']) - config.write('keyUsage = %s\n' % props['KeyUsage']) - config.write('subjectKeyIdentifier = hash\n') - config.flush() - - with tempfile.TemporaryDirectory() as tmpdir: - with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8') as pkey: - secret = boto3.client('secretsmanager').get_secret_value(SecretId=props['PrivateKeySecretId']) - pkey.write(secret['SecretString']) - pkey.flush() - - csr_file = os.path.join(tmpdir, 'csr.pem') - self_signed_cert = os.path.join(tmpdir, 'self-signed-cert.pem') - - subprocess.check_call([ - 'openssl', 'req', '-config', config.name, - '-key', pkey.name, - '-out', csr_file, - '-new' - ]) - - subprocess.check_call([ - 'openssl', 'x509', '-in', csr_file, - '-out', self_signed_cert, - '-req', - '-signkey', pkey.name, - '-days', '365' - ]) - - with open(csr_file) as csr: - with open(self_signed_cert) as cert: - return { - 'CSR': csr.read(), - 'SelfSignedCertificate': cert.read() - } - - elif event['RequestType'] == 'Delete': - # Nothing to do - the CSR isn't quite a "material" resource - return {} - - else: - raise Exception('Unsupported RequestType: %s' % event['RequestType']) - -def main(event, context): - log.getLogger().setLevel(log.INFO) - - try: - log.info('Input event: %s', json.dumps(event)) - attributes = handle_event(event, context.aws_request_id) - cfn_send(event, context, CFN_SUCCESS, attributes, event['LogicalResourceId']) - except KeyError as e: - cfn_send(event, context, CFN_FAILED, {}, reason="Invalid request: missing key %s" % str(e)) - except Exception as e: - log.exception(e) - cfn_send(event, context, CFN_FAILED, {}, reason=str(e)) - -#--------------------------------------------------------------------------------------------------- -# sends a response to cloudformation -def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None): - responseUrl = event['ResponseURL'] - log.info(responseUrl) - - body = json.dumps({ - 'Status': responseStatus, - 'Reason': reason or ('See the details in CloudWatch Log Stream: ' + context.log_stream_name), - 'PhysicalResourceId': physicalResourceId or context.log_stream_name, - 'StackId': event['StackId'], - 'RequestId': event['RequestId'], - 'LogicalResourceId': event['LogicalResourceId'], - 'NoEcho': noEcho, - 'Data': responseData, - }) - log.info("| response body:\n" + body) - - headers = { - 'content-type' : '', - 'content-length' : str(len(body)) - } - - try: - from botocore.vendored import requests - response = requests.put(responseUrl, data=body, headers=headers) - log.info("| status code: " + response.reason) - response.raise_for_status() - except Exception as e: - log.error("| unable to send response to CloudFormation") - raise e - -if __name__ == '__main__': - handle_event(json.load(sys.stdin), '61120008-4da7-40e1-b180-5ce50a6b90ad') diff --git a/lib/code-signing/private-key.ts b/lib/code-signing/private-key.ts index 7fb3e181..ba2a6bc2 100644 --- a/lib/code-signing/private-key.ts +++ b/lib/code-signing/private-key.ts @@ -63,11 +63,11 @@ export class RsaPrivateKeySecret extends cdk.Construct { props.deletionPolicy = props.deletionPolicy || cdk.DeletionPolicy.Retain; - const codeLocation = path.join(__dirname, 'private-key'); + const codeLocation = path.resolve(__dirname, '..', '..', 'custom-resource-handlers', 'bin', 'private-key'); const customResource = new lambda.SingletonFunction(this, 'ResourceHandler', { uuid: '72FD327D-3813-4632-9340-28EC437AA486', description: 'Generates an RSA Private Key and stores it in AWS Secrets Manager', - runtime: lambda.Runtime.Python36, + runtime: lambda.Runtime.NodeJS810, handler: 'index.main', code: new lambda.AssetCode(codeLocation), timeout: 300, diff --git a/lib/code-signing/private-key/index.py b/lib/code-signing/private-key/index.py deleted file mode 100644 index 781fe311..00000000 --- a/lib/code-signing/private-key/index.py +++ /dev/null @@ -1,136 +0,0 @@ -# Uses the openssl CLI to generate a new RSA Private Key with a modulus of a specified length. The resource _cannot_ be -# updated, as this usually wouldn't be the user's intention. Instead, a new instance should be created as part of key -# rotation. The private key will be stored in an AWS SecretsManager secret. -# -# Inputs: -# - KeySize (number, required): the RSA key modulus length in bits -# - SecretName (string, required): the name of the AWS SecretsManager secret that'll hold the private key (must _not_ exist) -# - KmsKeyId (string): the KMS CMK to use for the secret. If none is provided, the default key will be used -# - Description (string): the description to attach to the secret. -# -# Outputs: -# - Arn (string): The AWS SecretsManager secret ARN -# - VersionId (string): The AWS SecretsManager secret VersionId - -import logging as log -import json, os, sys - -CFN_SUCCESS = "SUCCESS" -CFN_FAILED = "FAILED" - -def handle_event(event, aws_request_id): - import boto3, shutil, subprocess, tempfile - - props = event['ResourceProperties'] - description = props.get('Description') - kmsKeyId = props.get('KmsKeyId') - - if event['RequestType'] == 'Update': - old_props = event['OldResourceProperties'] - # Prohibit updates to KeySize or SecretName, as those would require re-creating the key... - if old_props['KeySize'] != props['KeySize']: - raise Exception(f'The KeySize property cannot be updated (attempting to change from {old_props["KeySize"]} to {props["KeySize"]})') - if old_props['SecretName'] != props['SecretName']: - raise Exception(f'The SecretName property cannot be updated (attempting to change from {old_props["SecretName"]} to {props["SecretName"]})') - - opts = { - 'SecretId': event['PhysicalResourceId'], - 'ClientRequestToken': aws_request_id - } - - if description is not None: opts['Description'] = description - if kmsKeyId: opts['KmsKeyId'] = kmsKeyId - - ret = boto3.client('secretsmanager').update_secret(**opts) - - # No new version was created - go fetch the current latest VersionId - if ret.get('VersionId') is None: - opts = dict(SecretId=ret['ARN']) - while True: - response = boto3.client('secretsmanager').list_secret_version_ids(**opts) - for version in response['Versions']: - if 'AWSCURRENT' in version['VersionStages']: - ret['VersionId'] = version['VersionId'] - break - if ret['VersionId'] is not None or response.get('NextToken') is None: - break - opts['NextToken'] = response['NextToken'] - - return {'SecretArn': ret['ARN'], 'SecretVersionId': ret['VersionId']} - - elif event['RequestType'] == 'Create': - tmpdir = tempfile.mkdtemp() - with tempfile.TemporaryDirectory() as tmpdir: - pkey_file = os.path.join(tmpdir, 'private_key.pem') - subprocess.check_call(['openssl', 'genrsa', '-out', pkey_file, props['KeySize']], shell=False) - with open(pkey_file) as pkey: - opts = { - 'ClientRequestToken': aws_request_id, - 'Description': props.get('Description'), - 'Name': props['SecretName'], - 'SecretString': pkey.read() - } - - if description is not None: opts['Description'] = description - if kmsKeyId: opts['KmsKeyId'] = kmsKeyId - - ret = boto3.client('secretsmanager').create_secret(**opts) - return {'SecretArn': ret['ARN'], 'SecretVersionId': ret['VersionId']} - - elif event['RequestType'] == 'Delete': - if event['PhysicalResourceId'].startswith('arn:'): # Only if the resource had been successfully created before - boto3.client('secretsmanager').delete_secret(SecretId=event['PhysicalResourceId']) - return {'SecretArn': '', 'SecretVersionId': ''} - - else: - raise Exception('Unsupported RequestType: %s' % event['RequestType']) - -def main(event, context): - log.getLogger().setLevel(log.INFO) - - try: - log.info('Input event: %s', json.dumps(event)) - attributes = handle_event(event, context.aws_request_id) - cfn_send(event, context, CFN_SUCCESS, attributes, attributes['SecretArn']) - except KeyError as e: - log.exception(e) - cfn_send(event, context, CFN_FAILED, {}, reason="Invalid request: missing key %s" % str(e)) - except Exception as e: - log.exception(e) - cfn_send(event, context, CFN_FAILED, {}, reason=str(e)) - -#--------------------------------------------------------------------------------------------------- -# sends a response to cloudformation -def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None): - responseUrl = event['ResponseURL'] - log.info(responseUrl) - - responseBody = {} - responseBody['Status'] = responseStatus - responseBody['Reason'] = reason or ('See the details in CloudWatch Log Stream: ' + context.log_stream_name) - responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name - responseBody['StackId'] = event['StackId'] - responseBody['RequestId'] = event['RequestId'] - responseBody['LogicalResourceId'] = event['LogicalResourceId'] - responseBody['NoEcho'] = noEcho - responseBody['Data'] = responseData - - body = json.dumps(responseBody) - log.info("| response body:\n" + body) - - headers = { - 'content-type' : '', - 'content-length' : str(len(body)) - } - - try: - from botocore.vendored import requests - response = requests.put(responseUrl, data=body, headers=headers) - log.info("| status code: " + response.reason) - response.raise_for_status() - except Exception as e: - log.error("| unable to send response to CloudFormation") - raise e - -if __name__ == '__main__': - handle_event(json.load(sys.stdin), 'ec92d8a9-672c-4647-9d34-0d3159a2c692') diff --git a/lib/pgp-secret.ts b/lib/pgp-secret.ts index 7d824543..a06baf8a 100644 --- a/lib/pgp-secret.ts +++ b/lib/pgp-secret.ts @@ -75,7 +75,7 @@ export class PGPSecret extends cdk.Construct implements ICredentialPair { super(parent, name); const keyActions = ['kms:GenerateDataKey', 'kms:Encrypt', 'kms:Decrypt']; - const codeLocation = path.join(__dirname, 'pgp-secret'); + const codeLocation = path.resolve(__dirname, '..', 'custom-resource-handlers', 'bin', 'pgp-secret'); const fn = new lambda.SingletonFunction(this, 'Lambda', { uuid: 'f25803d3-054b-44fc-985f-4860d7d6ee74', @@ -83,7 +83,7 @@ export class PGPSecret extends cdk.Construct implements ICredentialPair { code: new lambda.AssetCode(codeLocation), handler: 'index.main', timeout: 300, - runtime: lambda.Runtime.Python36, + runtime: lambda.Runtime.NodeJS810, initialPolicy: [ new iam.PolicyStatement() .addActions('secretsmanager:CreateSecret', diff --git a/lib/pgp-secret/index.py b/lib/pgp-secret/index.py deleted file mode 100644 index 6f8b30f4..00000000 --- a/lib/pgp-secret/index.py +++ /dev/null @@ -1,156 +0,0 @@ -import logging as log -import json, os, sys - -CFN_SUCCESS = "SUCCESS" -CFN_FAILED = "FAILED" - -def handle_event(event, aws_request_id): - import random, string, tempfile, subprocess, boto3 - - props = event['ResourceProperties'] - description = props.get('Description') - new_key = event['RequestType'] == 'Create' - - if event['RequestType'] == 'Update': - old_props = event['OldResourceProperties'] - immutable_fields = ['Email', 'Expiry', 'Identity', 'KeySizeBits', 'ParameterName', 'SecretName', 'Version'] - for field in immutable_fields: - if props.get(field) != old_props.get(field): - log.info(f'New key required as {field} changed from {old_props.get(field)} to {props.get(field)}') - new_key = True - - if event['RequestType'] in ['Create', 'Update']: - if new_key: - passphrase = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16)) - - with tempfile.TemporaryDirectory() as tempdir_name: - os.environ['GNUPGHOME'] = tempdir_name - - with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8') as f: - f.write('Key-Type: RSA\n') - f.write('Key-Length: %s\n' % props['KeySizeBits']) - f.write('Name-Real: %s\n' % props['Identity']) - f.write('Name-Email: %s\n' % props['Email']) - f.write('Expire-Date: %s\n' % props['Expiry']) - f.write('Passphrase: %s\n' % passphrase) - f.write('%commit\n') - f.write('%echo done\n') - f.flush() - - print(f.name) - - subprocess.check_call(['gpg', - '--batch', '--gen-key', f.name], shell=False) - - keymaterial = subprocess.check_output(['gpg', - '--batch', '--yes', - '--passphrase', passphrase, '--export-secret-keys', '--armor'], shell=False).decode('utf-8') - - public_key = subprocess.check_output(['gpg', - '--batch', '--yes', - '--export', '--armor'], shell=False).decode('utf-8') - - call_args = dict( - ClientRequestToken=aws_request_id, - KmsKeyId=props.get('KeyArn'), - SecretString=json.dumps(dict(PrivateKey=keymaterial, Passphrase=passphrase))) - - if description is not None: call_args['Description'] = description - - if event['RequestType'] == 'Create': - ret = boto3.client('secretsmanager').create_secret( - Name=props['SecretName'], - **call_args) - else: - ret = boto3.client('secretsmanager').update_secret( - SecretId=event['PhysicalResourceId'], - **call_args) - - boto3.client('ssm').put_parameter( - Name=props['ParameterName'], - Description=f'Public part of OpenPGP key {ret["ARN"]}', - Value=public_key, - Type='String', - Overwrite=(event['RequestType'] == 'Update')) - else: - call_args = dict(SecretId=event['PhysicalResourceId'], - ClientRequestToken=aws_request_id, - KmsKeyId=props.get('KeyArn')) - - if description is not None: call_args['Description'] = description - - ret = boto3.client('secretsmanager').update_secret(**call_args) - - # No new version was created - go fetch the current latest VersionId - if ret.get('VersionId') is None: - opts = dict(SecretId=ret['ARN']) - while True: - response = boto3.client('secretsmanager').list_secret_version_ids(**opts) - for version in response['Versions']: - if 'AWSCURRENT' in version['VersionStages']: - ret['VersionId'] = version['VersionId'] - break - if ret['VersionId'] is not None or response.get('NextToken') is None: - break - opts['NextToken'] = response['NextToken'] - - return { - 'SecretArn': ret['ARN'], - 'SecretVersionId': ret['VersionId'], - 'ParameterName': props['ParameterName'] - } - - if event['RequestType'] == 'Delete': - if event['PhysicalResourceId'].startswith('arn:'): # Only if successfully created before - boto3.client('ssm').delete_parameter(Name=props['ParameterName']) - boto3.client('secretsmanager').delete_secret(SecretId=event['PhysicalResourceId']) - - return { 'SecretArn': '', 'SecretVersionId': '', 'ParameterName': '' } - - -def main(event, context): - log.getLogger().setLevel(log.INFO) - - try: - log.info('Input event: %s', json.dumps(event)) - attributes = handle_event(event, context.aws_request_id) - cfn_send(event, context, CFN_SUCCESS, attributes, attributes['SecretArn']) - except Exception as e: - log.exception(e) - cfn_send(event, context, CFN_FAILED, {}, event.get('PhysicalResourceId') or context.log_stream_name, reason=str(e)) - -#--------------------------------------------------------------------------------------------------- -# sends a response to cloudformation -def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None): - responseUrl = event['ResponseURL'] - log.info(responseUrl) - - responseBody = {} - responseBody['Status'] = responseStatus - responseBody['Reason'] = reason or ('See the details in CloudWatch Log Stream: ' + context.log_stream_name) - responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name - responseBody['StackId'] = event['StackId'] - responseBody['RequestId'] = event['RequestId'] - responseBody['LogicalResourceId'] = event['LogicalResourceId'] - responseBody['NoEcho'] = noEcho - responseBody['Data'] = responseData - - body = json.dumps(responseBody) - log.info("| response body:\n" + body) - - headers = { - 'content-type' : '', - 'content-length' : str(len(body)) - } - - try: - from botocore.vendored import requests - response = requests.put(responseUrl, data=body, headers=headers) - log.info("| status code: " + response.reason) - response.raise_for_status() - except Exception as e: - log.error("| unable to send response to CloudFormation") - raise e - -if __name__ == '__main__': - handle_event(json.load(sys.stdin), '7547bafb-5125-44c5-83e4-6eae56a52cce') diff --git a/package.json b/package.json index 07f66e77..2d23c814 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "lib/index.js", "types": "lib/index.d.ts", "scripts": { - "build": "tsc && tslint --fix --project .", + "build": "npm run build-custom-resource-handlers && tsc && tslint --fix --project .", + "build-custom-resource-handlers": "/bin/bash ./build-custom-resource-handlers.sh", "package": "/bin/bash ./package.sh", "watch": "tsc -w", "test": "/bin/bash ./test.sh", diff --git a/tsconfig.json b/tsconfig.json index 56ab69f1..537625b2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,6 @@ "experimentalDecorators": true, "jsx": "react", "jsxFactory": "jsx.create" - } + }, + "exclude": ["custom-resource-handlers"] } From 949722503e33c777e52d0264eb96eb4c80f616a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Thu, 20 Dec 2018 17:18:42 +0100 Subject: [PATCH 3/9] Add tests to the custom resource handlers (helper modules only) --- build-custom-resource-handlers.sh | 1 + .../src/_cloud-formation.ts | 4 +- custom-resource-handlers/src/_exec.ts | 6 +- custom-resource-handlers/src/_rmrf.ts | 2 +- .../src/certificate-signing-request.ts | 11 ++- custom-resource-handlers/src/pgp-secret.ts | 7 +- custom-resource-handlers/src/private-key.ts | 2 +- package.json | 5 + .../_cloud-formation.test.ts | 99 +++++++++++++++++++ test/custom-resource-handlers/_exec.test.ts | 17 ++++ test/custom-resource-handlers/_rmrf.test.ts | 11 +++ .../_secrets-manager.test.ts | 76 ++++++++++++++ 12 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 test/custom-resource-handlers/_cloud-formation.test.ts create mode 100644 test/custom-resource-handlers/_exec.test.ts create mode 100644 test/custom-resource-handlers/_rmrf.test.ts create mode 100644 test/custom-resource-handlers/_secrets-manager.test.ts diff --git a/build-custom-resource-handlers.sh b/build-custom-resource-handlers.sh index 69a92851..7bd8f5f0 100644 --- a/build-custom-resource-handlers.sh +++ b/build-custom-resource-handlers.sh @@ -2,6 +2,7 @@ set -euo pipefail compile="tsc --alwaysStrict + --inlineSourceMap --lib ES2017 --module CommonJS --moduleResolution Node diff --git a/custom-resource-handlers/src/_cloud-formation.ts b/custom-resource-handlers/src/_cloud-formation.ts index ce3a8fc5..c4830031 100644 --- a/custom-resource-handlers/src/_cloud-formation.ts +++ b/custom-resource-handlers/src/_cloud-formation.ts @@ -23,7 +23,7 @@ export function sendResponse(event: Event, console.log(`Response body: ${responseBody}`); const parsedUrl = url.parse(event.ResponseURL); - const options = { + const options: https.RequestOptions = { headers: { 'content-length': responseBody.length, 'content-type': '', @@ -42,7 +42,7 @@ export function sendResponse(event: Event, if (resp.statusCode === 200) { return ok(); } - ko(`Unexpected error sending resopnse to CloudFormation: HTTP ${resp.statusCode} (${resp.statusMessage})`); + ko(new Error(`Unexpected error sending resopnse to CloudFormation: HTTP ${resp.statusCode} (${resp.statusMessage})`)); }); req.on('error', ko); diff --git a/custom-resource-handlers/src/_exec.ts b/custom-resource-handlers/src/_exec.ts index c9eedd50..fa0f009b 100644 --- a/custom-resource-handlers/src/_exec.ts +++ b/custom-resource-handlers/src/_exec.ts @@ -1,8 +1,8 @@ import childProcess = require('child_process'); -export = function _exec(command: string): Promise { +export = function _exec(command: string, ...args: string[]): Promise { return new Promise((ok, ko) => { - const child = childProcess.spawn(command, { shell: false, stdio: ['ignore', 'pipe', 'inherit'] }); + const child = childProcess.spawn(command, args, { shell: false, stdio: ['ignore', 'pipe', 'inherit'] }); const chunks = new Array(); child.stdout.on('data', (chunk) => { @@ -15,7 +15,7 @@ export = function _exec(command: string): Promise { if (code === 0) { return ok(Buffer.concat(chunks).toString('utf8')); } - ko(signal != null ? `Killed by ${signal}` : `Returned ${code}`); + ko(new Error(signal != null ? `Killed by ${signal}` : `Exited with status ${code}`)); }); }); }; diff --git a/custom-resource-handlers/src/_rmrf.ts b/custom-resource-handlers/src/_rmrf.ts index 3af40d24..7b3ddea2 100644 --- a/custom-resource-handlers/src/_rmrf.ts +++ b/custom-resource-handlers/src/_rmrf.ts @@ -12,4 +12,4 @@ export = async function _rmrf(filePath: string): Promise { } else { await util.promisify(fs.unlink)(filePath); } -} +}; diff --git a/custom-resource-handlers/src/certificate-signing-request.ts b/custom-resource-handlers/src/certificate-signing-request.ts index 80c2b290..38cf5909 100644 --- a/custom-resource-handlers/src/certificate-signing-request.ts +++ b/custom-resource-handlers/src/certificate-signing-request.ts @@ -55,9 +55,16 @@ async function _createSelfSignedCertificate(event: cfn.Event): Promise; + +test('sends the correct response to CloudFormation', () => { + httpsRequest.mockImplementationOnce((opts, cb) => { + expect(opts.headers['content-type']).toBe(''); + expect(opts.hostname).toBe('host.domain.tld'); + expect(opts.method).toBe('PUT'); + expect(opts.port).toBe('123'); + expect(opts.path).toBe('/path/to/resource?query=string'); + + const emitter = new EventEmitter(); + let payload: string; + + return { + on(evt: string, callback: (...args: any[]) => void) { + emitter.on(evt, callback); + return this; + }, + write(str: string) { + payload = str; + return this; + }, + end: jest.fn().mockImplementationOnce(() => { + expect(JSON.parse(payload || '{}')).toEqual({ + Data: data, + LogicalResourceId: event.LogicalResourceId, + PhysicalResourceId: physicalId, + Reason: reason, + RequestId: event.RequestId, + StackId: event.StackId, + Status: status, + }); + cb({ statusCode: 200 }); + }), + }; + }); + + return expect(cfn.sendResponse(event, status, physicalId, data, reason)) + .resolves.toBe(undefined); +}); + +test('fails if the PUT request returns non-200', () => { + httpsRequest.mockImplementationOnce((opts, cb) => { + expect(opts.headers['content-type']).toBe(''); + expect(opts.hostname).toBe('host.domain.tld'); + expect(opts.method).toBe('PUT'); + expect(opts.port).toBe('123'); + expect(opts.path).toBe('/path/to/resource?query=string'); + + const emitter = new EventEmitter(); + let payload: string; + + return { + on(evt: string, callback: (...args: any[]) => void) { + emitter.on(evt, callback); + return this; + }, + write(str: string) { + payload = str; + return this; + }, + end: jest.fn().mockImplementationOnce(() => { + expect(JSON.parse(payload || '{}')).toEqual({ + Data: data, + LogicalResourceId: event.LogicalResourceId, + PhysicalResourceId: physicalId, + Reason: reason, + RequestId: event.RequestId, + StackId: event.StackId, + Status: status, + }); + cb({ statusCode: 500, statusMessage: 'Internal Error' }); + }), + }; + }); + + return expect(cfn.sendResponse(event, status, physicalId, data, reason)) + .rejects.toEqual(new Error('Unexpected error sending resopnse to CloudFormation: HTTP 500 (Internal Error)')); +}); diff --git a/test/custom-resource-handlers/_exec.test.ts b/test/custom-resource-handlers/_exec.test.ts new file mode 100644 index 00000000..8f475ab2 --- /dev/null +++ b/test/custom-resource-handlers/_exec.test.ts @@ -0,0 +1,17 @@ +import _exec = require('../../custom-resource-handlers/src/_exec'); + +test('forwards stdout (single-line)', () => + expect(_exec('node', '-e', 'process.stdout.write("OKAY")')).resolves.toBe("OKAY") +); + +test('forwards stdout (multi-line)', () => + expect(_exec('node', '-e', 'process.stdout.write("OKAY\\nGREAT")')).resolves.toBe("OKAY\nGREAT") +); + +test('fails if the command exits with non-zero status', () => + expect(_exec('node', '-e', 'process.exit(10)')).rejects.toEqual(new Error('Exited with status 10')) +); + +test('fails if the command is killed by a signal', () => + expect(_exec('node', '-e', 'process.kill(process.pid, "SIGKILL")')).rejects.toEqual(new Error('Killed by SIGKILL')) +); diff --git a/test/custom-resource-handlers/_rmrf.test.ts b/test/custom-resource-handlers/_rmrf.test.ts new file mode 100644 index 00000000..b912eee0 --- /dev/null +++ b/test/custom-resource-handlers/_rmrf.test.ts @@ -0,0 +1,11 @@ +import fs = require('fs'); +import os = require('os'); +import path = require('path'); + +import _rmrf = require('../../custom-resource-handlers/src/_rmrf'); + +test('resmoves a full directory', () => { + const dir = fs.mkdtempSync(os.tmpdir()); + fs.writeFileSync(path.join(dir, 'exhibit-A'), 'Exhibit A'); + return expect(_rmrf(dir).then(() => fs.existsSync(dir))).resolves.toBe(false); +}); diff --git a/test/custom-resource-handlers/_secrets-manager.test.ts b/test/custom-resource-handlers/_secrets-manager.test.ts new file mode 100644 index 00000000..c55dbbd6 --- /dev/null +++ b/test/custom-resource-handlers/_secrets-manager.test.ts @@ -0,0 +1,76 @@ +import aws = require('aws-sdk'); +import { resolveCurrentVersionId } from '../../custom-resource-handlers/src/_secrets-manager'; + +test('resolves to the correct VersionId', async () => { + const secretId = "Sekr37"; + const versionId = "Shiney-VersionId"; + + const client = new aws.SecretsManager(); + client.listSecretVersionIds = jest.fn() + .mockName('secretsManager.listSecretVersionIds') + .mockImplementationOnce((opts: aws.SecretsManager.ListSecretVersionIdsRequest) => { + expect(opts.NextToken).toBe(undefined); + return { + promise() { + return Promise.resolve({ + Versions: [ + { VersionId: 'Version1', VersionStages: ['OLDVERSION', 'BUGGYVERSION'] } + ], + NextToken: '1' + }); + } + }; + }) + .mockImplementationOnce((opts: aws.SecretsManager.ListSecretVersionIdsRequest) => { + expect(opts.NextToken).toBe('1'); + return { + promise() { + return Promise.resolve({ + Versions: [ + { VersionId: versionId, VersionStages: ['NEWVERSION', 'AWSCURRENT', 'IDONTCARE'] } + ], + NextToken: undefined + }); + } + }; + }); + + return expect(await resolveCurrentVersionId(secretId, client)).toBe(versionId); +}); + +test('throws if there is no AWSCURRENT version', () => { + const secretId = "Sekr37"; + + const client = new aws.SecretsManager(); + client.listSecretVersionIds = jest.fn() + .mockName('secretsManager.listSecretVersionIds') + .mockImplementationOnce((opts: aws.SecretsManager.ListSecretVersionIdsRequest) => { + expect(opts.NextToken).toBe(undefined); + return { + promise() { + return Promise.resolve({ + Versions: [ + { VersionId: 'Version1', VersionStages: ['OLDVERSION', 'BUGGYVERSION'] } + ], + NextToken: '1' + }); + } + }; + }) + .mockImplementationOnce((opts: aws.SecretsManager.ListSecretVersionIdsRequest) => { + expect(opts.NextToken).toBe('1'); + return { + promise() { + return Promise.resolve({ + Versions: [ + { VersionId: 'Version2', VersionStages: ['NEWVERSION', 'IDONTCARE'] } + ], + NextToken: undefined + }); + } + }; + }); + + return expect(resolveCurrentVersionId(secretId, client)).rejects + .toEqual(new Error(`Unable to determine the current VersionId of ${secretId}`)); +}); From dc3f7ba40104a7aaab32e8dad1f9c2b8b666e22c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Fri, 21 Dec 2018 16:10:49 +0100 Subject: [PATCH 4/9] Ad more tests --- custom-resource-handlers/src/_exec.ts | 3 +- .../src/certificate-signing-request.ts | 6 +- custom-resource-handlers/src/pgp-secret.ts | 14 +- package-lock.json | 8 +- package.json | 25 +-- .../_cloud-formation.test.ts | 2 +- test/custom-resource-handlers/_exec.test.ts | 4 +- test/custom-resource-handlers/_rmrf.test.ts | 2 +- .../_secrets-manager.test.ts | 4 +- .../certificate-signing-request.test.ts | 203 ++++++++++++++++++ .../pgp-secret.test.ts | 181 ++++++++++++++++ .../private-key.test.ts | 188 ++++++++++++++++ tsconfig.json | 3 +- 13 files changed, 611 insertions(+), 32 deletions(-) create mode 100644 test/custom-resource-handlers/certificate-signing-request.test.ts create mode 100644 test/custom-resource-handlers/pgp-secret.test.ts create mode 100644 test/custom-resource-handlers/private-key.test.ts diff --git a/custom-resource-handlers/src/_exec.ts b/custom-resource-handlers/src/_exec.ts index fa0f009b..d65619c3 100644 --- a/custom-resource-handlers/src/_exec.ts +++ b/custom-resource-handlers/src/_exec.ts @@ -1,8 +1,9 @@ import childProcess = require('child_process'); +import process = require('process'); export = function _exec(command: string, ...args: string[]): Promise { return new Promise((ok, ko) => { - const child = childProcess.spawn(command, args, { shell: false, stdio: ['ignore', 'pipe', 'inherit'] }); + const child = childProcess.spawn(command, args, { env: process.env, shell: false, stdio: ['ignore', 'pipe', 'inherit'] }); const chunks = new Array(); child.stdout.on('data', (chunk) => { diff --git a/custom-resource-handlers/src/certificate-signing-request.ts b/custom-resource-handlers/src/certificate-signing-request.ts index 38cf5909..7cf7607f 100644 --- a/custom-resource-handlers/src/certificate-signing-request.ts +++ b/custom-resource-handlers/src/certificate-signing-request.ts @@ -26,7 +26,7 @@ export async function main(event: cfn.Event, context: lambda.Context): Promise { `extendedKeyUsage = ${event.ResourceProperties.ExtendedKeyUsage}`, `keyUsage = ${event.ResourceProperties.KeyUsage}`, 'subjectKeyIdentifier = hash', - ].join('\n')); + ].join('\n'), { encoding: 'utf8' }); return file; } @@ -108,6 +108,6 @@ async function _retrievePrivateKey(event: cfn.Event, dir: string): Promise { }); return expect(cfn.sendResponse(event, status, physicalId, data, reason)) - .rejects.toEqual(new Error('Unexpected error sending resopnse to CloudFormation: HTTP 500 (Internal Error)')); + .rejects.toThrow('Unexpected error sending resopnse to CloudFormation: HTTP 500 (Internal Error)'); }); diff --git a/test/custom-resource-handlers/_exec.test.ts b/test/custom-resource-handlers/_exec.test.ts index 8f475ab2..3dd3ee2a 100644 --- a/test/custom-resource-handlers/_exec.test.ts +++ b/test/custom-resource-handlers/_exec.test.ts @@ -9,9 +9,9 @@ test('forwards stdout (multi-line)', () => ); test('fails if the command exits with non-zero status', () => - expect(_exec('node', '-e', 'process.exit(10)')).rejects.toEqual(new Error('Exited with status 10')) + expect(_exec('node', '-e', 'process.exit(10)')).rejects.toThrow('Exited with status 10') ); test('fails if the command is killed by a signal', () => - expect(_exec('node', '-e', 'process.kill(process.pid, "SIGKILL")')).rejects.toEqual(new Error('Killed by SIGKILL')) + expect(_exec('node', '-e', 'process.kill(process.pid, "SIGKILL")')).rejects.toThrow('Killed by SIGKILL') ); diff --git a/test/custom-resource-handlers/_rmrf.test.ts b/test/custom-resource-handlers/_rmrf.test.ts index b912eee0..a2a150f4 100644 --- a/test/custom-resource-handlers/_rmrf.test.ts +++ b/test/custom-resource-handlers/_rmrf.test.ts @@ -7,5 +7,5 @@ import _rmrf = require('../../custom-resource-handlers/src/_rmrf'); test('resmoves a full directory', () => { const dir = fs.mkdtempSync(os.tmpdir()); fs.writeFileSync(path.join(dir, 'exhibit-A'), 'Exhibit A'); - return expect(_rmrf(dir).then(() => fs.existsSync(dir))).resolves.toBe(false); + return expect(_rmrf(dir).then(() => fs.existsSync(dir))).resolves.toBeFalsy(); }); diff --git a/test/custom-resource-handlers/_secrets-manager.test.ts b/test/custom-resource-handlers/_secrets-manager.test.ts index c55dbbd6..81a834e2 100644 --- a/test/custom-resource-handlers/_secrets-manager.test.ts +++ b/test/custom-resource-handlers/_secrets-manager.test.ts @@ -71,6 +71,6 @@ test('throws if there is no AWSCURRENT version', () => { }; }); - return expect(resolveCurrentVersionId(secretId, client)).rejects - .toEqual(new Error(`Unable to determine the current VersionId of ${secretId}`)); + return expect(resolveCurrentVersionId(secretId, client)) + .rejects.toThrow(`Unable to determine the current VersionId of ${secretId}`); }); diff --git a/test/custom-resource-handlers/certificate-signing-request.test.ts b/test/custom-resource-handlers/certificate-signing-request.test.ts new file mode 100644 index 00000000..351d75c9 --- /dev/null +++ b/test/custom-resource-handlers/certificate-signing-request.test.ts @@ -0,0 +1,203 @@ +import aws = require('aws-sdk'); +import fs = require('fs'); +import { createMockInstance } from 'jest-create-mock-instance'; +import path = require('path'); +import cfn = require('../../custom-resource-handlers/src/_cloud-formation'); +import lambda = require('../../custom-resource-handlers/src/_lambda'); + +const context: lambda.Context = { awsRequestId: '90E99AAE-B120-409A-9156-0C5925FDD996' } as lambda.Context; +const eventBase = { + LogicalResourceId: 'ResourceID12345689', + ResponseURL: 'https://response/url', + RequestId: '5EF100FB-0075-4716-970B-FBCA05BFE118', + ResourceProperties: { + DnCommonName: 'Test', + DnCountry: 'FR', + DnStateOrProvince: 'TestLand', + DnLocality: 'Test City', + DnOrganizationName: 'Test, Inc.', + DnOrganizationalUnitName: 'QA Department', + DnEmailAddress: 'test@acme.test', + KeyUsage: 'critical,use-the-key', + ExtendedKeyUsage: 'critical,abuse-the-key', + }, + ResourceType: 'Custom::Resource::Type', + StackId: 'StackID-1324597', +}; +const mockTempDir = '/tmp/directory/is/phony'; +const mockPrivateKey = 'Pretend private key'; +const mockCsr = 'Pretend CSR'; +const mockCertificate = 'Pretend Certificate'; + +const csrDocument = `[ req ] +default_md = sha256 +distinguished_name = dn +prompt = no +req_extensions = extensions +string_mask = utf8only +utf8 = yes + +[ dn ] +CN = ${eventBase.ResourceProperties.DnCommonName} +C = ${eventBase.ResourceProperties.DnCountry} +ST = ${eventBase.ResourceProperties.DnStateOrProvince} +L = ${eventBase.ResourceProperties.DnLocality} +O = ${eventBase.ResourceProperties.DnOrganizationName} +OU = ${eventBase.ResourceProperties.DnOrganizationalUnitName} +emailAddress = ${eventBase.ResourceProperties.DnEmailAddress} + +[ extensions ] +extendedKeyUsage = ${eventBase.ResourceProperties.ExtendedKeyUsage} +keyUsage = ${eventBase.ResourceProperties.KeyUsage} +subjectKeyIdentifier = hash`; + +jest.spyOn(fs, 'mkdtemp').mockName('fs.mkdtemp') + .mockImplementation(async (base, cb) => { + await expect(base).toBe(require('os').tmpdir()); + cb(undefined, mockTempDir); + }); +jest.spyOn(fs, 'readFile').mockName('fs.readFile') + .mockImplementation(async (file, opts, cb) => { + expect(opts.encoding).toBe('utf8'); + switch (file) { + case require('path').join(mockTempDir, 'csr.pem'): + return cb(undefined, mockCsr); + case require('path').join(mockTempDir, 'cert.pem'): + return cb(undefined, mockCertificate); + default: + cb(new Error('Unexpected call!')); + } + }); +const mockWriteFile = jest.spyOn(fs, 'writeFile').mockName('fs.writeFile') + .mockImplementation((_pth, _data, _opts, cb) => cb()); +const mockSecretsManager = createMockInstance(aws.SecretsManager); +jest.spyOn(aws, 'SecretsManager').mockImplementation(() => mockSecretsManager); +mockSecretsManager.getSecretValue = jest.fn().mockName('SecretsManager.getSecretValue') + .mockImplementation(() => ({ promise: () => Promise.resolve({ SecretString: mockPrivateKey }) })) as any; +const mockExec = jest.fn().mockName('_exec').mockRejectedValue(new Error('Unexpected call!')); +jest.mock('../../custom-resource-handlers/src/_exec', () => mockExec); +jest.mock('../../custom-resource-handlers/src/_rmrf', () => mockRmrf); +const mockRmrf = jest.fn().mockName('_rmrf') + .mockResolvedValue(undefined); +jest.mock('../../custom-resource-handlers/src/_rmrf', () => mockRmrf); +cfn.sendResponse = jest.fn().mockName('cfn.sendResponse').mockResolvedValue(undefined); + +beforeEach(() => jest.clearAllMocks()); + +test('Create', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.CREATE, + PhysicalResourceId: undefined, + ...eventBase, + }; + + mockExec.mockImplementation(async (cmd: string, ...args: string[]) => { + await expect(cmd).toBe('openssl'); + switch (args[0]) { + case 'req': + await expect(args).toEqual(['req', '-config', require('path').join(mockTempDir, 'csr.config'), + '-key', require('path').join(mockTempDir, 'private_key.pem'), + '-out', require('path').join(mockTempDir, 'csr.pem'), + '-new']); + break; + case 'x509': + await expect(args).toEqual(['x509', '-in', require('path').join(mockTempDir, 'csr.pem'), + '-out', require('path').join(mockTempDir, 'cert.pem'), + '-req', + '-signkey', require('path').join(mockTempDir, 'private_key.pem'), + '-days', '365']); + break; + default: + throw new Error(`Unexpected call!`); + } + return ''; + }); + + const { main } = require('../../custom-resource-handlers/src/certificate-signing-request'); + await expect(main(event, context)).resolves.toBe(undefined); + + await expect(mockWriteFile) + .toBeCalledWith(path.join(mockTempDir, 'csr.config'), + csrDocument, + expect.anything(), + expect.any(Function)); + await expect(mockWriteFile) + .toBeCalledWith(path.join(mockTempDir, 'private_key.pem'), + mockPrivateKey, + expect.anything(), + expect.any(Function)); + await expect(mockRmrf).toBeCalledWith(mockTempDir); + return expect(cfn.sendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + event.LogicalResourceId, + { CSR: mockCsr, SelfSignedCertificate: mockCertificate }); +}); + +test('Update', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.UPDATE, + PhysicalResourceId: eventBase.LogicalResourceId, + OldResourceProperties: eventBase.ResourceProperties, + ...eventBase, + }; + + mockExec.mockImplementation(async (cmd: string, ...args: string[]) => { + await expect(cmd).toBe('openssl'); + switch (args[0]) { + case 'req': + await expect(args).toEqual(['req', '-config', require('path').join(mockTempDir, 'csr.config'), + '-key', require('path').join(mockTempDir, 'private_key.pem'), + '-out', require('path').join(mockTempDir, 'csr.pem'), + '-new']); + break; + case 'x509': + await expect(args).toEqual(['x509', '-in', require('path').join(mockTempDir, 'csr.pem'), + '-out', require('path').join(mockTempDir, 'cert.pem'), + '-req', + '-signkey', require('path').join(mockTempDir, 'private_key.pem'), + '-days', '365']); + break; + default: + throw new Error(`Unexpected call!`); + } + return ''; + }); + + const { main } = require('../../custom-resource-handlers/src/certificate-signing-request'); + await expect(main(event, context)).resolves.toBe(undefined); + + await expect(mockWriteFile) + .toBeCalledWith(path.join(mockTempDir, 'csr.config'), + csrDocument, + expect.anything(), + expect.any(Function)); + await expect(mockWriteFile) + .toBeCalledWith(path.join(mockTempDir, 'private_key.pem'), + mockPrivateKey, + expect.anything(), + expect.any(Function)); + await expect(mockRmrf).toBeCalledWith(mockTempDir); + return expect(cfn.sendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + event.LogicalResourceId, + { CSR: mockCsr, SelfSignedCertificate: mockCertificate }); +}); + +test('Delete', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.DELETE, + PhysicalResourceId: eventBase.LogicalResourceId, + ...eventBase, + }; + + const { main } = require('../../custom-resource-handlers/src/certificate-signing-request'); + await expect(main(event, context)).resolves.toBe(undefined); + + return expect(cfn.sendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + event.LogicalResourceId, + { CSR: '', SelfSignedCertificate: '' }); +}); diff --git a/test/custom-resource-handlers/pgp-secret.test.ts b/test/custom-resource-handlers/pgp-secret.test.ts new file mode 100644 index 00000000..fb23f9a1 --- /dev/null +++ b/test/custom-resource-handlers/pgp-secret.test.ts @@ -0,0 +1,181 @@ +import aws = require('aws-sdk'); +import crypto = require('crypto'); +import fs = require('fs'); +import { createMockInstance } from 'jest-create-mock-instance'; +import path = require('path'); +import cfn = require('../../custom-resource-handlers/src/_cloud-formation'); +import lambda = require('../../custom-resource-handlers/src/_lambda'); +import secretsManager = require('../../custom-resource-handlers/src/_secrets-manager'); + +const context: lambda.Context = { awsRequestId: 'E3802D69-27F8-44F0-9E4C-3329A8736A4C' } as any; +const mockTmpDir = '/tmp/directory/is/phony'; +const mockPrivateKey = '---BEGIN RSA FAKE PRIVATE KEY---'; +const mockPublicKey = '---BEGIN RSA FAKE PUBLIC KEY---'; +const mockEventBase = { + LogicalResourceId: 'ResourceID12345689', + ResponseURL: 'https://response/url', + RequestId: '5EF100FB-0075-4716-970B-FBCA05BFE118', + ResourceProperties: { + KeySizeBits: 4_096, + Identity: 'Test Identity', + Email: 'test@amazon.com', + Expiry: '1d', + SecretName: 'Secret/Name/Shhhhh', + ParameterName: 'Parameter/Name', + KeyArn: 'alias/KmsKey', + Description: 'Description', + }, + ResourceType: 'Custom::Resource::Type', + StackId: 'StackID-1324597', +}; + +const secretArn = 'arn::::::secret'; +const secretVersionId = 'secret-version-id'; + +const passphrase = crypto.randomBytes(32); + +const keyConfig = `Key-Type: RSA +Key-Length: ${mockEventBase.ResourceProperties.KeySizeBits} +Name-Real: ${mockEventBase.ResourceProperties.Identity} +Name-Email: ${mockEventBase.ResourceProperties.Email} +Expire-Date: ${mockEventBase.ResourceProperties.Expiry} +Passphrase: ${passphrase.toString('base64')} +%commit +%echo done`; + +jest.spyOn(crypto, 'randomBytes').mockImplementation(() => passphrase); +jest.spyOn(fs, 'mkdtemp').mockImplementation(async (base, cb) => { + await expect(base).toBe(require('os').tmpdir()); + cb(undefined, mockTmpDir); +}); +const writeFile = jest.spyOn(fs, 'writeFile').mockName('fs.writeFile').mockImplementation((_pth, _data, _opts, cb) => cb()); +jest.mock('../../custom-resource-handlers/src/_exec', () => async (cmd: string, ...args: string[]) => { + await expect(cmd).toBe('gpg'); + await expect(args).toContain('--batch'); + if (args.indexOf('--gen-key') !== -1) { + await expect(args[args.indexOf('--gen-key') + 1]).toBe(require('path').join(mockTmpDir, 'key.config')); + return ''; + } + await expect(args).toContain('--yes'); + await expect(args).toContain('--armor'); + if (args.indexOf('--export') !== -1) { + return mockPublicKey; + } else if (args.indexOf('--export-secret-keys') !== -1) { + return mockPrivateKey; + } + throw new Error(`Invalid call to _exec`); +}); +const mockSecretsManager = createMockInstance(aws.SecretsManager); +jest.spyOn(aws, 'SecretsManager').mockImplementation(() => mockSecretsManager); +const mockSSM = createMockInstance(aws.SSM); +jest.spyOn(aws, 'SSM').mockImplementation(() => mockSSM); +const mockSendResponse = jest.spyOn(cfn, 'sendResponse').mockName('cfn.sendResponse').mockResolvedValue(undefined); +const mockRmrf = jest.fn().mockName('_rmrf').mockResolvedValue(undefined); +jest.mock('../../custom-resource-handlers/src/_rmrf', () => mockRmrf); + +test('Create', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.CREATE, + PhysicalResourceId: undefined, + ...mockEventBase + }; + + mockSecretsManager.createSecret = jest.fn().mockName('SecretsManager.createSecret') + .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn, VersionId: secretVersionId}) })) as any; + mockSSM.putParameter = jest.fn().mockName('SSM.putParameter') + .mockImplementation(() => ({ promise: () => Promise.resolve({}) })) as any; + + const { main } = require('../../custom-resource-handlers/src/pgp-secret'); + await expect(main(event, context)).resolves.toBe(undefined); + + await expect(writeFile) + .toBeCalledWith(path.join(mockTmpDir, 'key.config'), + keyConfig, + expect.anything(), + expect.any(Function)); + await expect(mockRmrf) + .toBeCalledWith(mockTmpDir); + await expect(mockSecretsManager.createSecret) + .toBeCalledWith({ + ClientRequestToken: context.awsRequestId, + Description: event.ResourceProperties.Description, + KmsKeyId: event.ResourceProperties.KeyArn, + Name: event.ResourceProperties.SecretName, + SecretString: mockPrivateKey, + }); + await expect(mockSSM.putParameter) + .toBeCalledWith({ + Description: `Public part of OpenPGP key ${secretArn} (version ${secretVersionId})`, + Name: event.ResourceProperties.ParameterName, + Overwrite: false, + Type: 'String', + Value: mockPublicKey, + }); + return expect(mockSendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + secretArn, + { + SecretArn: secretArn, + SecretVersionId: secretVersionId, + ParameterName: event.ResourceProperties.ParameterName, + }); +}); + +test('Update', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.UPDATE, + PhysicalResourceId: secretArn, + OldResourceProperties: { + ...mockEventBase.ResourceProperties, + Description: 'Old Description', + KeyArn: 'alias/OldKey', + }, + ...mockEventBase, + }; + + mockSecretsManager.updateSecret = jest.fn().mockName('SecretsManager.updateSecret') + .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn }) })) as any; + secretsManager.resolveCurrentVersionId = jest.fn().mockName('resolveCurrentVersionId') + .mockResolvedValue(secretVersionId); + + const { main } = require('../../custom-resource-handlers/src/pgp-secret'); + await expect(main(event, context)).resolves.toBe(undefined); + await expect(mockSecretsManager.updateSecret) + .toBeCalledWith({ + ClientRequestToken: context.awsRequestId, + SecretId: secretArn, + Description: event.ResourceProperties.Description, + KmsKeyId: event.ResourceProperties.KeyArn + }); + return expect(mockSendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + secretArn, + { SecretArn: secretArn, SecretVersionId: secretVersionId, ParameterName: event.ResourceProperties.ParameterName }); +}); + +test('Delete', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.DELETE, + PhysicalResourceId: secretArn, + ...mockEventBase + }; + + mockSecretsManager.deleteSecret = jest.fn().mockName('SecretsManager.deleteSecret') + .mockImplementation(() => ({ promise: () => Promise.resolve({}) })) as any; + mockSSM.deleteParameter = jest.fn().mockName('SSM.deleteParameter') + .mockImplementation(() => ({ promise: () => Promise.resolve({}) })) as any; + + const { main } = require('../../custom-resource-handlers/src/pgp-secret'); + await expect(main(event, context)).resolves.toBe(undefined); + await expect(mockSecretsManager.deleteSecret) + .toBeCalledWith({ SecretId: secretArn }); + await expect(mockSSM.deleteParameter) + .toBeCalledWith({ Name: event.ResourceProperties.ParameterName }); + return expect(mockSendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + '', + { SecretArn: '' }); +}); diff --git a/test/custom-resource-handlers/private-key.test.ts b/test/custom-resource-handlers/private-key.test.ts new file mode 100644 index 00000000..4418093b --- /dev/null +++ b/test/custom-resource-handlers/private-key.test.ts @@ -0,0 +1,188 @@ +import aws = require('aws-sdk'); +import fs = require('fs'); +import { createMockInstance } from 'jest-create-mock-instance'; +import cfn = require('../../custom-resource-handlers/src/_cloud-formation'); +import lambda = require('../../custom-resource-handlers/src/_lambda'); +import secretsManager = require('../../custom-resource-handlers/src/_secrets-manager'); + +const context: lambda.Context = { awsRequestId: '90E99AAE-B120-409A-9156-0C5925FDD996' } as lambda.Context; +const mockKeySize = 4_096; +const eventBase = { + LogicalResourceId: 'ResourceID12345689', + ResponseURL: 'https://response/url', + RequestId: '5EF100FB-0075-4716-970B-FBCA05BFE118', + ResourceProperties: { + Description: 'Description of my secret', + KeySize: 4_096, + KmsKeyId: 'alias/KmsKey', + SecretName: 'Sekret/Name/Shhhh', + }, + ResourceType: 'Custom::Resource::Type', + StackId: 'StackID-1324597', +}; +const mockTmpDir = '/tmp/directory/is/phony'; +const mockPrivateKey = 'Phony PEM-Encoded Private Key'; +const secretArn = 'arn::::::secret'; +const secretVersionId = 'secret-version-id'; + +cfn.sendResponse = jest.fn().mockName('cfn.sendResponse').mockResolvedValue(undefined); +jest.mock('../../custom-resource-handlers/src/_exec', () => async (cmd: string, ...args: string[]) => { + await expect(cmd).toBe('openssl'); + await expect(args).toEqual(['genrsa', '-out', require('path').join(mockTmpDir, 'private_key.pem'), mockKeySize]); + return ''; +}); +jest.spyOn(fs, 'mkdtemp').mockName('fs.mkdtemp') + .mockImplementation(async (base, cb) => { + await expect(base).toBe(require('os').tmpdir()); + cb(undefined, mockTmpDir); + }); +jest.spyOn(fs, 'readFile').mockName('fs.readFile') + .mockImplementation(async (file, opts, cb) => { + await expect(file).toBe(require('path').join(mockTmpDir, 'private_key.pem')); + await expect(opts.encoding).toBe('utf8'); + return cb(undefined, mockPrivateKey); + }); +const mockSecretsManager = createMockInstance(aws.SecretsManager); +jest.spyOn(aws, 'SecretsManager').mockImplementation(() => mockSecretsManager); +mockSecretsManager.createSecret = jest.fn().mockName('SecretsManager.createSecret') + .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn, VersionId: secretVersionId }) })) as any; +mockSecretsManager.updateSecret = jest.fn().mockName('SecretsManager.updateSecret') + .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn }) })) as any; +mockSecretsManager.deleteSecret = jest.fn().mockName('SecretsManager.deleteSecret') + .mockImplementation(() => ({ promise: () => Promise.resolve({}) })) as any; +const mockRmrf = jest.fn().mockName('_rmrf').mockResolvedValue(undefined); +jest.mock('../../custom-resource-handlers/src/_rmrf', () => mockRmrf); +secretsManager.resolveCurrentVersionId = jest.fn().mockName('resolveCurrentVersionId') + .mockResolvedValue(secretVersionId); + +beforeEach(() => jest.clearAllMocks()); + +test('Create', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.CREATE, + PhysicalResourceId: undefined, + ...eventBase, + }; + + const { main } = require('../../custom-resource-handlers/src/private-key'); + await expect(main(event, context)).resolves.toBe(undefined); + + await expect(mockSecretsManager.createSecret) + .toBeCalledWith({ + ClientRequestToken: context.awsRequestId, + Description: event.ResourceProperties.Description, + KmsKeyId: event.ResourceProperties.KmsKeyId, + Name: event.ResourceProperties.SecretName, + SecretString: mockPrivateKey, + }); + await expect(mockSecretsManager.updateSecret).not.toBeCalled(); + await expect(mockSecretsManager.deleteSecret).not.toBeCalled(); + await expect(mockRmrf).toBeCalledWith(mockTmpDir); + return expect(cfn.sendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + secretArn, + { SecretArn: secretArn, SecretVersionId: secretVersionId }); +}); + +test('Update (changing KeySize)', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.UPDATE, + PhysicalResourceId: secretArn, + OldResourceProperties: { + ...eventBase.ResourceProperties, + KeySize: mockKeySize * 2, + }, + ...eventBase, + }; + + const { main } = require('../../custom-resource-handlers/src/private-key'); + await expect(main(event, context)).resolves.toBe(undefined); + + await expect(mockSecretsManager.createSecret).not.toBeCalled(); + await expect(mockSecretsManager.updateSecret).not.toBeCalled(); + await expect(mockSecretsManager.deleteSecret).not.toBeCalled(); + return expect(cfn.sendResponse) + .toBeCalledWith(event, + cfn.Status.FAILED, + secretArn, + { SecretArn: '' }, + expect.stringContaining('The KeySize property cannot be updated')); +}); + +test('Update (changing KeySize)', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.UPDATE, + PhysicalResourceId: secretArn, + OldResourceProperties: { + ...eventBase.ResourceProperties, + KeySize: mockKeySize * 2, + }, + ...eventBase, + }; + + const { main } = require('../../custom-resource-handlers/src/private-key'); + await expect(main(event, context)).resolves.toBe(undefined); + + await expect(mockSecretsManager.createSecret).not.toBeCalled(); + await expect(mockSecretsManager.updateSecret).not.toBeCalled(); + await expect(mockSecretsManager.deleteSecret).not.toBeCalled(); + return expect(cfn.sendResponse) + .toBeCalledWith(event, + cfn.Status.FAILED, + secretArn, + { SecretArn: '' }, + expect.stringContaining('The KeySize property cannot be updated')); +}); + +test('Update (changing Description and KmsKeyId)', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.UPDATE, + PhysicalResourceId: secretArn, + OldResourceProperties: { + ...eventBase.ResourceProperties, + Description: 'Old description', + KmsKeyId: 'alias/OldKmsKey', + }, + ...eventBase, + }; + + const { main } = require('../../custom-resource-handlers/src/private-key'); + await expect(main(event, context)).resolves.toBe(undefined); + + await expect(mockSecretsManager.createSecret).not.toBeCalled(); + await expect(mockSecretsManager.updateSecret) + .toBeCalledWith({ + ClientRequestToken: context.awsRequestId, + Description: event.ResourceProperties.Description, + KmsKeyId: event.ResourceProperties.KmsKeyId, + SecretId: secretArn, + }); + await expect(mockSecretsManager.deleteSecret).not.toBeCalled(); + return expect(cfn.sendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + secretArn, + { SecretArn: secretArn, SecretVersionId: secretVersionId }); +}); + +test('Delete', async () => { + const event: cfn.Event = { + RequestType: cfn.RequestType.DELETE, + PhysicalResourceId: secretArn, + ...eventBase, + }; + + const { main } = require('../../custom-resource-handlers/src/private-key'); + await expect(main(event, context)).resolves.toBe(undefined); + + await expect(mockSecretsManager.createSecret).not.toBeCalled(); + await expect(mockSecretsManager.updateSecret).not.toBeCalled(); + await expect(mockSecretsManager.deleteSecret) + .toBeCalledWith({ SecretId: secretArn }); + return expect(cfn.sendResponse) + .toBeCalledWith(event, + cfn.Status.SUCCESS, + '', + { SecretArn: '', SecretVersionId: '' }); +}); diff --git a/tsconfig.json b/tsconfig.json index 537625b2..56ab69f1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,5 @@ "experimentalDecorators": true, "jsx": "react", "jsxFactory": "jsx.create" - }, - "exclude": ["custom-resource-handlers"] + } } From 6ef2ca1cf2002aef3eac020e92d631e2c70a580f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Fri, 21 Dec 2018 17:24:36 +0100 Subject: [PATCH 5/9] Reflect new situation in integ expectation file --- .../src/_cloud-formation.ts | 2 + custom-resource-handlers/src/_exec.ts | 5 +- .../src/certificate-signing-request.ts | 2 +- custom-resource-handlers/src/pgp-secret.ts | 2 +- custom-resource-handlers/src/private-key.ts | 4 +- .../certificate-signing-request.test.ts | 47 +++++++++---------- .../pgp-secret.test.ts | 6 +-- .../private-key.test.ts | 5 +- test/expected.json | 24 +++++----- test/test-stack.ts | 2 +- 10 files changed, 45 insertions(+), 54 deletions(-) diff --git a/custom-resource-handlers/src/_cloud-formation.ts b/custom-resource-handlers/src/_cloud-formation.ts index c4830031..bd0a8f93 100644 --- a/custom-resource-handlers/src/_cloud-formation.ts +++ b/custom-resource-handlers/src/_cloud-formation.ts @@ -39,6 +39,8 @@ export function sendResponse(event: Event, console.log('Sending response...'); const req = https.request(options, resp => { + // tslint:disable-next-line:no-console + console.log(`Received HTTP ${resp.statusCode} (${resp.statusMessage})`); if (resp.statusCode === 200) { return ok(); } diff --git a/custom-resource-handlers/src/_exec.ts b/custom-resource-handlers/src/_exec.ts index d65619c3..4d457c88 100644 --- a/custom-resource-handlers/src/_exec.ts +++ b/custom-resource-handlers/src/_exec.ts @@ -6,10 +6,7 @@ export = function _exec(command: string, ...args: string[]): Promise { const child = childProcess.spawn(command, args, { env: process.env, shell: false, stdio: ['ignore', 'pipe', 'inherit'] }); const chunks = new Array(); - child.stdout.on('data', (chunk) => { - process.stdout.write(chunk); - chunks.push(chunk); - }); + child.stdout.on('data', chunk => chunks.push(chunk)); child.once('error', ko); child.once('exit', (code, signal) => { diff --git a/custom-resource-handlers/src/certificate-signing-request.ts b/custom-resource-handlers/src/certificate-signing-request.ts index 7cf7607f..5eb530d4 100644 --- a/custom-resource-handlers/src/certificate-signing-request.ts +++ b/custom-resource-handlers/src/certificate-signing-request.ts @@ -50,7 +50,7 @@ async function handleEvent(event: cfn.Event, _context: lambda.Context): Promise< } async function _createSelfSignedCertificate(event: cfn.Event): Promise { - const tempDir = await util.promisify(fs.mkdtemp)(os.tmpdir()); + const tempDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'x509CSR-')); try { const configFile = await _makeCsrConfig(event, tempDir); const pkeyFile = await _retrievePrivateKey(event, tempDir); diff --git a/custom-resource-handlers/src/pgp-secret.ts b/custom-resource-handlers/src/pgp-secret.ts index e274937e..4d28fc28 100644 --- a/custom-resource-handlers/src/pgp-secret.ts +++ b/custom-resource-handlers/src/pgp-secret.ts @@ -71,7 +71,7 @@ async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { const passPhrase = crypto.randomBytes(32).toString('base64'); - const tempDir = await util.promisify(fs.mkdtemp)(os.tmpdir()); + const tempDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'OpenPGP-')); try { process.env.GNUPGHOME = tempDir; diff --git a/custom-resource-handlers/src/private-key.ts b/custom-resource-handlers/src/private-key.ts index 9217fa5c..7c4e8afe 100644 --- a/custom-resource-handlers/src/private-key.ts +++ b/custom-resource-handlers/src/private-key.ts @@ -51,10 +51,10 @@ interface ResourceAttributes { } async function _createSecret(event: cfn.CreateEvent, context: lambda.Context): Promise { - const tmpDir = await util.promisify(fs.mkdtemp)(os.tmpdir()); + const tmpDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'x509PrivateKey-')); try { const pkeyFile = path.join(tmpDir, 'private_key.pem'); - _exec('openssl', 'genrsa', '-out', pkeyFile, event.ResourceProperties.KeySize); + await _exec('openssl', 'genrsa', '-out', pkeyFile, event.ResourceProperties.KeySize); const result = await secretsManager.createSecret({ ClientRequestToken: context.awsRequestId, Description: event.ResourceProperties.Description, diff --git a/test/custom-resource-handlers/certificate-signing-request.test.ts b/test/custom-resource-handlers/certificate-signing-request.test.ts index 351d75c9..bddcaa69 100644 --- a/test/custom-resource-handlers/certificate-signing-request.test.ts +++ b/test/custom-resource-handlers/certificate-signing-request.test.ts @@ -24,7 +24,7 @@ const eventBase = { ResourceType: 'Custom::Resource::Type', StackId: 'StackID-1324597', }; -const mockTempDir = '/tmp/directory/is/phony'; +const mockTmpDir = '/tmp/directory/is/phony'; const mockPrivateKey = 'Pretend private key'; const mockCsr = 'Pretend CSR'; const mockCertificate = 'Pretend Certificate'; @@ -52,17 +52,14 @@ keyUsage = ${eventBase.ResourceProperties.KeyUsage} subjectKeyIdentifier = hash`; jest.spyOn(fs, 'mkdtemp').mockName('fs.mkdtemp') - .mockImplementation(async (base, cb) => { - await expect(base).toBe(require('os').tmpdir()); - cb(undefined, mockTempDir); - }); + .mockImplementation(async (_, cb) => cb(undefined, mockTmpDir)); jest.spyOn(fs, 'readFile').mockName('fs.readFile') .mockImplementation(async (file, opts, cb) => { expect(opts.encoding).toBe('utf8'); switch (file) { - case require('path').join(mockTempDir, 'csr.pem'): + case require('path').join(mockTmpDir, 'csr.pem'): return cb(undefined, mockCsr); - case require('path').join(mockTempDir, 'cert.pem'): + case require('path').join(mockTmpDir, 'cert.pem'): return cb(undefined, mockCertificate); default: cb(new Error('Unexpected call!')); @@ -95,16 +92,16 @@ test('Create', async () => { await expect(cmd).toBe('openssl'); switch (args[0]) { case 'req': - await expect(args).toEqual(['req', '-config', require('path').join(mockTempDir, 'csr.config'), - '-key', require('path').join(mockTempDir, 'private_key.pem'), - '-out', require('path').join(mockTempDir, 'csr.pem'), + await expect(args).toEqual(['req', '-config', require('path').join(mockTmpDir, 'csr.config'), + '-key', require('path').join(mockTmpDir, 'private_key.pem'), + '-out', require('path').join(mockTmpDir, 'csr.pem'), '-new']); break; case 'x509': - await expect(args).toEqual(['x509', '-in', require('path').join(mockTempDir, 'csr.pem'), - '-out', require('path').join(mockTempDir, 'cert.pem'), + await expect(args).toEqual(['x509', '-in', require('path').join(mockTmpDir, 'csr.pem'), + '-out', require('path').join(mockTmpDir, 'cert.pem'), '-req', - '-signkey', require('path').join(mockTempDir, 'private_key.pem'), + '-signkey', require('path').join(mockTmpDir, 'private_key.pem'), '-days', '365']); break; default: @@ -117,16 +114,16 @@ test('Create', async () => { await expect(main(event, context)).resolves.toBe(undefined); await expect(mockWriteFile) - .toBeCalledWith(path.join(mockTempDir, 'csr.config'), + .toBeCalledWith(path.join(mockTmpDir, 'csr.config'), csrDocument, expect.anything(), expect.any(Function)); await expect(mockWriteFile) - .toBeCalledWith(path.join(mockTempDir, 'private_key.pem'), + .toBeCalledWith(path.join(mockTmpDir, 'private_key.pem'), mockPrivateKey, expect.anything(), expect.any(Function)); - await expect(mockRmrf).toBeCalledWith(mockTempDir); + await expect(mockRmrf).toBeCalledWith(mockTmpDir); return expect(cfn.sendResponse) .toBeCalledWith(event, cfn.Status.SUCCESS, @@ -146,16 +143,16 @@ test('Update', async () => { await expect(cmd).toBe('openssl'); switch (args[0]) { case 'req': - await expect(args).toEqual(['req', '-config', require('path').join(mockTempDir, 'csr.config'), - '-key', require('path').join(mockTempDir, 'private_key.pem'), - '-out', require('path').join(mockTempDir, 'csr.pem'), + await expect(args).toEqual(['req', '-config', require('path').join(mockTmpDir, 'csr.config'), + '-key', require('path').join(mockTmpDir, 'private_key.pem'), + '-out', require('path').join(mockTmpDir, 'csr.pem'), '-new']); break; case 'x509': - await expect(args).toEqual(['x509', '-in', require('path').join(mockTempDir, 'csr.pem'), - '-out', require('path').join(mockTempDir, 'cert.pem'), + await expect(args).toEqual(['x509', '-in', require('path').join(mockTmpDir, 'csr.pem'), + '-out', require('path').join(mockTmpDir, 'cert.pem'), '-req', - '-signkey', require('path').join(mockTempDir, 'private_key.pem'), + '-signkey', require('path').join(mockTmpDir, 'private_key.pem'), '-days', '365']); break; default: @@ -168,16 +165,16 @@ test('Update', async () => { await expect(main(event, context)).resolves.toBe(undefined); await expect(mockWriteFile) - .toBeCalledWith(path.join(mockTempDir, 'csr.config'), + .toBeCalledWith(path.join(mockTmpDir, 'csr.config'), csrDocument, expect.anything(), expect.any(Function)); await expect(mockWriteFile) - .toBeCalledWith(path.join(mockTempDir, 'private_key.pem'), + .toBeCalledWith(path.join(mockTmpDir, 'private_key.pem'), mockPrivateKey, expect.anything(), expect.any(Function)); - await expect(mockRmrf).toBeCalledWith(mockTempDir); + await expect(mockRmrf).toBeCalledWith(mockTmpDir); return expect(cfn.sendResponse) .toBeCalledWith(event, cfn.Status.SUCCESS, diff --git a/test/custom-resource-handlers/pgp-secret.test.ts b/test/custom-resource-handlers/pgp-secret.test.ts index fb23f9a1..8429cf85 100644 --- a/test/custom-resource-handlers/pgp-secret.test.ts +++ b/test/custom-resource-handlers/pgp-secret.test.ts @@ -44,10 +44,8 @@ Passphrase: ${passphrase.toString('base64')} %echo done`; jest.spyOn(crypto, 'randomBytes').mockImplementation(() => passphrase); -jest.spyOn(fs, 'mkdtemp').mockImplementation(async (base, cb) => { - await expect(base).toBe(require('os').tmpdir()); - cb(undefined, mockTmpDir); -}); +jest.spyOn(fs, 'mkdtemp') + .mockImplementation(async (_, cb) => cb(undefined, mockTmpDir)); const writeFile = jest.spyOn(fs, 'writeFile').mockName('fs.writeFile').mockImplementation((_pth, _data, _opts, cb) => cb()); jest.mock('../../custom-resource-handlers/src/_exec', () => async (cmd: string, ...args: string[]) => { await expect(cmd).toBe('gpg'); diff --git a/test/custom-resource-handlers/private-key.test.ts b/test/custom-resource-handlers/private-key.test.ts index 4418093b..f29b4d5a 100644 --- a/test/custom-resource-handlers/private-key.test.ts +++ b/test/custom-resource-handlers/private-key.test.ts @@ -32,10 +32,7 @@ jest.mock('../../custom-resource-handlers/src/_exec', () => async (cmd: string, return ''; }); jest.spyOn(fs, 'mkdtemp').mockName('fs.mkdtemp') - .mockImplementation(async (base, cb) => { - await expect(base).toBe(require('os').tmpdir()); - cb(undefined, mockTmpDir); - }); + .mockImplementation(async (_, cb) => cb(undefined, mockTmpDir)); jest.spyOn(fs, 'readFile').mockName('fs.readFile') .mockImplementation(async (file, opts, cb) => { await expect(file).toBe(require('path').join(mockTmpDir, 'private_key.pem')); diff --git a/test/expected.json b/test/expected.json index fc5bac49..5245e330 100644 --- a/test/expected.json +++ b/test/expected.json @@ -1536,7 +1536,7 @@ Resources: Value: 68a05363083174 - Name: SIGNING_KEY_SCOPE Type: PLAINTEXT - Value: delivlib/signing + Value: delivlib-test/CodeSign - Name: FOR_REAL Type: PLAINTEXT Value: "true" @@ -1718,7 +1718,7 @@ Resources: Value: ./CHANGELOG.md - Name: SIGNING_KEY_SCOPE Type: PLAINTEXT - Value: delivlib/signing + Value: delivlib-test/CodeSign - Name: GITHUB_TOKEN Type: PLAINTEXT Value: @@ -2032,7 +2032,7 @@ Resources: Fn::GetAtt: - SingletonLambda72FD327D38134632934028EC437AA4865ADA6EFF - Arn - ResourceVersion: jHRqf4H63RNQK2j59bvZ7Suik/qdEzev8joYVXPy0N8= + ResourceVersion: 7QkaemBF+TIb+CnNaAUmZV/WjZfMtTPjJD9mIclE4Go= Description: The PEM-encoded private key of the x509 Code-Signing Certificate KeySize: 2048 SecretName: delivlib-test/X509CodeSigningKey/RSAPrivateKey @@ -2049,7 +2049,7 @@ Resources: Fn::GetAtt: - CreateCSR541F67826DCF49A78C5A67715ADD9E4C8F4169F6 - Arn - ResourceVersion: lg2A/uKz4Qjf3sXeKWLLZjjkhGST12DlLVRvq8Qn+aw= + ResourceVersion: zADQgpm0x8aBNmX2S1JYCGOLMul9gMDQOM0WfvUdw9o= PrivateKeySecretId: Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 @@ -2164,7 +2164,7 @@ Resources: Fn::GetAtt: - SingletonLambda72FD327D38134632934028EC437AA486ServiceRole225F46F5 - Arn - Runtime: python3.6 + Runtime: nodejs8.10 Description: Generates an RSA Private Key and stores it in AWS Secrets Manager Timeout: 300 DependsOn: @@ -2231,7 +2231,7 @@ Resources: Fn::GetAtt: - CreateCSR541F67826DCF49A78C5A67715ADD9E4CServiceRoleD2990C92 - Arn - Runtime: python3.6 + Runtime: nodejs8.10 Description: Creates a Certificate Signing Request document for an x509 certificate Timeout: 300 DependsOn: @@ -2296,14 +2296,14 @@ Resources: - Arn Resource: "*" Version: "2012-10-17" - Description: Encryption key for PGP secret delivlib/signing/SigningKey + Description: Encryption key for PGP secret delivlib-test/CodeSign/SigningKey DeletionPolicy: Retain Metadata: aws:cdk:path: delivlib-test/CodeSign/Key/Resource CodeSignKeyAliasCEA10CD5: Type: AWS::KMS::Alias Properties: - AliasName: alias/delivlib/signing/SigningKeyKey + AliasName: alias/delivlib-test/CodeSign/SigningKeyKey TargetKeyId: Fn::GetAtt: - CodeSignKey2302B99A @@ -2317,17 +2317,17 @@ Resources: Fn::GetAtt: - SingletonLambdaf25803d3054b44fc985f4860d7d6ee746203BDE6 - Arn - ResourceVersion: +ai21G6fYSfCaSmWQGY88zMFxRirgnfInnT4SpH1IVo= + ResourceVersion: ptUhvrN9nd2pmTOr3amtOUb/QpyW4B+iG3hLHvO9qEI= Identity: aws-cdk-dev Email: aws-cdk-dev+delivlib@amazon.com Expiry: 4y KeySizeBits: 4096 - SecretName: delivlib/signing/SigningKey + SecretName: delivlib-test/CodeSign/SigningKey KeyArn: Fn::GetAtt: - CodeSignKey2302B99A - Arn - ParameterName: /delivlib/signing/SigningKey.pub + ParameterName: /delivlib-test/CodeSign/SigningKey.pub Version: 1 Metadata: aws:cdk:path: delivlib-test/CodeSign/Secret/Resource @@ -2402,7 +2402,7 @@ Resources: Fn::GetAtt: - SingletonLambdaf25803d3054b44fc985f4860d7d6ee74ServiceRole410148CF - Arn - Runtime: python3.6 + Runtime: nodejs8.10 Description: Generates an OpenPGP Key and stores the private key in Secrets Manager and the public key in an SSM Parameter Timeout: 300 diff --git a/test/test-stack.ts b/test/test-stack.ts index f5549742..db7cc637 100644 --- a/test/test-stack.ts +++ b/test/test-stack.ts @@ -76,7 +76,7 @@ export class TestStack extends cdk.Stack { const signingKey = new delivlib.OpenPgpKey(this, 'CodeSign', { email: 'aws-cdk-dev+delivlib@amazon.com', identity: 'aws-cdk-dev', - secretName: 'delivlib/signing' + secretName: this.path + '/CodeSign', }); pipeline.publishToMaven({ From 0d850ff17b1f5627846b0286d6d1c2ee72c10441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Fri, 21 Dec 2018 17:29:45 +0100 Subject: [PATCH 6/9] Minor fix --- custom-resource-handlers/src/certificate-signing-request.ts | 2 +- custom-resource-handlers/src/pgp-secret.ts | 2 +- custom-resource-handlers/src/private-key.ts | 2 +- test/custom-resource-handlers/pgp-secret.test.ts | 2 +- test/custom-resource-handlers/private-key.test.ts | 2 +- test/expected.json | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/custom-resource-handlers/src/certificate-signing-request.ts b/custom-resource-handlers/src/certificate-signing-request.ts index 5eb530d4..b4e874d8 100644 --- a/custom-resource-handlers/src/certificate-signing-request.ts +++ b/custom-resource-handlers/src/certificate-signing-request.ts @@ -18,7 +18,7 @@ export async function main(event: cfn.Event, context: lambda.Context): Promise { return expect(mockSendResponse) .toBeCalledWith(event, cfn.Status.SUCCESS, - '', + event.PhysicalResourceId, { SecretArn: '' }); }); diff --git a/test/custom-resource-handlers/private-key.test.ts b/test/custom-resource-handlers/private-key.test.ts index f29b4d5a..39ec7117 100644 --- a/test/custom-resource-handlers/private-key.test.ts +++ b/test/custom-resource-handlers/private-key.test.ts @@ -180,6 +180,6 @@ test('Delete', async () => { return expect(cfn.sendResponse) .toBeCalledWith(event, cfn.Status.SUCCESS, - '', + event.PhysicalResourceId, { SecretArn: '', SecretVersionId: '' }); }); diff --git a/test/expected.json b/test/expected.json index 5245e330..0bcc2d8d 100644 --- a/test/expected.json +++ b/test/expected.json @@ -2032,7 +2032,7 @@ Resources: Fn::GetAtt: - SingletonLambda72FD327D38134632934028EC437AA4865ADA6EFF - Arn - ResourceVersion: 7QkaemBF+TIb+CnNaAUmZV/WjZfMtTPjJD9mIclE4Go= + ResourceVersion: 3z1iEo2gliu6HO/ThFS47cru9WTvGrAk6ZwXVRGAnD8= Description: The PEM-encoded private key of the x509 Code-Signing Certificate KeySize: 2048 SecretName: delivlib-test/X509CodeSigningKey/RSAPrivateKey @@ -2049,7 +2049,7 @@ Resources: Fn::GetAtt: - CreateCSR541F67826DCF49A78C5A67715ADD9E4C8F4169F6 - Arn - ResourceVersion: zADQgpm0x8aBNmX2S1JYCGOLMul9gMDQOM0WfvUdw9o= + ResourceVersion: WYxqU7hWKjmXHFvu6uy4NwfQVFZeU8PP67aSOgW1XVo= PrivateKeySecretId: Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 @@ -2317,7 +2317,7 @@ Resources: Fn::GetAtt: - SingletonLambdaf25803d3054b44fc985f4860d7d6ee746203BDE6 - Arn - ResourceVersion: ptUhvrN9nd2pmTOr3amtOUb/QpyW4B+iG3hLHvO9qEI= + ResourceVersion: F2nr5wuNNEBZ3229VtPce9lV6s688L1KF9/x1ItEQ+U= Identity: aws-cdk-dev Email: aws-cdk-dev+delivlib@amazon.com Expiry: 4y From 195558ff3d2a9b7540708be177c4158ff8d733fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Thu, 27 Dec 2018 13:41:08 +0100 Subject: [PATCH 7/9] Fix the tests --- .../src/_cloud-formation.ts | 46 ++++++++++- custom-resource-handlers/src/_rmrf.ts | 15 ++-- .../src/_secrets-manager.ts | 17 ----- .../src/certificate-signing-request.ts | 45 ++++------- custom-resource-handlers/src/pgp-secret.ts | 60 ++++++--------- custom-resource-handlers/src/private-key.ts | 54 +++++-------- .../certificate-signing-request.ts | 2 +- lib/code-signing/code-signing-certificate.ts | 22 +++--- lib/code-signing/private-key.ts | 3 +- lib/credential-pair.ts | 8 +- lib/pgp-secret.ts | 18 ++--- lib/publishing.ts | 4 +- lib/signing-key.ts | 2 +- package.json | 3 +- .../_cloud-formation.test.ts | 8 ++ test/custom-resource-handlers/_rmrf.test.ts | 2 +- .../_secrets-manager.test.ts | 76 ------------------- .../certificate-signing-request.test.ts | 20 ++--- .../pgp-secret.test.ts | 30 ++++---- .../private-key.test.ts | 50 ++++++------ test/expected.json | 12 +-- test/pgp-secret.test.ts | 2 +- 22 files changed, 213 insertions(+), 286 deletions(-) delete mode 100644 custom-resource-handlers/src/_secrets-manager.ts delete mode 100644 test/custom-resource-handlers/_secrets-manager.test.ts diff --git a/custom-resource-handlers/src/_cloud-formation.ts b/custom-resource-handlers/src/_cloud-formation.ts index bd0a8f93..b97c7ec1 100644 --- a/custom-resource-handlers/src/_cloud-formation.ts +++ b/custom-resource-handlers/src/_cloud-formation.ts @@ -1,12 +1,54 @@ import https = require('https'); import url = require('url'); +import lambda = require('./_lambda'); + +export type LambdaHandler = (event: Event, context: lambda.Context) => Promise; +export type ResourceHandler = (event: Event, context: lambda.Context) => Promise; + +/** + * Implements a Lambda CloudFormation custom resource handler. + * + * @param handleEvent the handler function that creates, updates and deletes the resource. + * @param refAttribute the name of the attribute holindg the Physical ID of the resource. + * @returns a handler function. + */ +export function customResourceHandler(handleEvent: ResourceHandler): LambdaHandler { + return async (event, context) => { + try { + // tslint:disable-next-line:no-console + console.log(`Input event: ${JSON.stringify(event)}`); + + const attributes = await handleEvent(event, context); + + // tslint:disable-next-line:no-console + console.log(`Attributes: ${JSON.stringify(attributes)}`); + + await exports.sendResponse(event, Status.SUCCESS, attributes.Ref, attributes); + } catch (e) { + // tslint:disable-next-line:no-console + console.error(e); + await exports.sendResponse(event, Status.FAILED, event.PhysicalResourceId, {}, e.message); + } + }; +} + +/** + * General shape of custom resource attributes. + */ +export interface ResourceAttributes { + /** The physical reference to this resource instance. */ + Ref: string; + + /** Other attributes of the resource. */ + [key: string]: string | undefined; +} /** * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-responses.html */ export function sendResponse(event: Event, status: Status, - physicalResourceId: string, + physicalResourceId: string = event.PhysicalResourceId || event.LogicalResourceId, data: { [name: string]: string | undefined }, reason?: string) { const responseBody = JSON.stringify({ @@ -47,7 +89,7 @@ export function sendResponse(event: Event, ko(new Error(`Unexpected error sending resopnse to CloudFormation: HTTP ${resp.statusCode} (${resp.statusMessage})`)); }); - req.on('error', ko); + req.once('error', ko); req.write(responseBody); req.end(); diff --git a/custom-resource-handlers/src/_rmrf.ts b/custom-resource-handlers/src/_rmrf.ts index 7b3ddea2..fe19cc98 100644 --- a/custom-resource-handlers/src/_rmrf.ts +++ b/custom-resource-handlers/src/_rmrf.ts @@ -2,14 +2,19 @@ import fs = require('fs'); import path = require('path'); import util = require('util'); +const readdir = util.promisify(fs.readdir); +const rmdir = util.promisify(fs.rmdir); +const stat = util.promisify(fs.stat); +const unlink = util.promisify(fs.unlink); + export = async function _rmrf(filePath: string): Promise { - const stat = await util.promisify(fs.stat)(filePath); - if (stat.isDirectory()) { - for (const child of await util.promisify(fs.readdir)(filePath)) { + const fstat = await stat(filePath); + if (fstat.isDirectory()) { + for (const child of await readdir(filePath)) { await _rmrf(path.join(filePath, child)); } - await util.promisify(fs.rmdir)(filePath); + await rmdir(filePath); } else { - await util.promisify(fs.unlink)(filePath); + await unlink(filePath); } }; diff --git a/custom-resource-handlers/src/_secrets-manager.ts b/custom-resource-handlers/src/_secrets-manager.ts deleted file mode 100644 index a5516262..00000000 --- a/custom-resource-handlers/src/_secrets-manager.ts +++ /dev/null @@ -1,17 +0,0 @@ -import aws = require('aws-sdk'); - -export async function resolveCurrentVersionId(secretId: string, - client: aws.SecretsManager = new aws.SecretsManager()): Promise { - const request: aws.SecretsManager.ListSecretVersionIdsRequest = { SecretId: secretId }; - do { - const response = await client.listSecretVersionIds(request).promise(); - request.NextToken = response.NextToken; - if (!response.Versions) { continue; } - for (const version of response.Versions) { - if (version.VersionId && version.VersionStages && version.VersionStages.indexOf('AWSCURRENT') !== -1) { - return version.VersionId; - } - } - } while (request.NextToken != null); - throw new Error(`Unable to determine the current VersionId of ${secretId}`); -} diff --git a/custom-resource-handlers/src/certificate-signing-request.ts b/custom-resource-handlers/src/certificate-signing-request.ts index b4e874d8..bcd99f2a 100644 --- a/custom-resource-handlers/src/certificate-signing-request.ts +++ b/custom-resource-handlers/src/certificate-signing-request.ts @@ -9,48 +9,32 @@ import _exec = require('./_exec'); import lambda = require('./_lambda'); import _rmrf = require('./_rmrf'); +const mkdtemp = util.promisify(fs.mkdtemp); +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + const secretsManager = new aws.SecretsManager(); -export async function main(event: cfn.Event, context: lambda.Context): Promise { - try { - // tslint:disable-next-line:no-console - console.log(`Input event: ${JSON.stringify(event)}`); - const attributes = await handleEvent(event, context); - await cfn.sendResponse(event, - cfn.Status.SUCCESS, - event.LogicalResourceId || event.PhysicalResourceId || context.logStreamName, - attributes); - } catch (e) { - // tslint:disable-next-line:no-console - console.error(e); - await cfn.sendResponse(event, - cfn.Status.FAILED, - event.LogicalResourceId, - { CSR: '', SelfSignedCertificate: '' }, - e.message); - } -} +exports.handler = cfn.customResourceHandler(handleEvent); -interface ResourceAttributes { +interface ResourceAttributes extends cfn.ResourceAttributes { CSR: string; SelfSignedCertificate: string; - - [name: string]: string | undefined; } -async function handleEvent(event: cfn.Event, _context: lambda.Context): Promise { +async function handleEvent(event: cfn.Event, _context: lambda.Context): Promise { switch (event.RequestType) { case cfn.RequestType.CREATE: case cfn.RequestType.UPDATE: return _createSelfSignedCertificate(event); case cfn.RequestType.DELETE: // Nothing to do - this is not a "Physical" resource - return { CSR: '', SelfSignedCertificate: '' }; + return { Ref: event.LogicalResourceId }; } } async function _createSelfSignedCertificate(event: cfn.Event): Promise { - const tempDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'x509CSR-')); + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'x509CSR-')); try { const configFile = await _makeCsrConfig(event, tempDir); const pkeyFile = await _retrievePrivateKey(event, tempDir); @@ -66,8 +50,9 @@ async function _createSelfSignedCertificate(event: cfn.Event): Promise { const file = path.join(dir, 'csr.config'); - await util.promisify(fs.writeFile)(file, [ + await writeFile(file, [ '[ req ]', 'default_md = sha256', 'distinguished_name = dn', @@ -106,8 +91,8 @@ async function _retrievePrivateKey(event: cfn.Event, dir: string): Promise { - try { - // tslint:disable-next-line:no-console - console.log(`Input event: ${JSON.stringify(event)}`); - const attributes = await handleEvent(event, context); - await cfn.sendResponse(event, - cfn.Status.SUCCESS, - attributes.SecretArn || event.PhysicalResourceId || context.logStreamName, - attributes); - } catch (e) { - // tslint:disable-next-line:no-console - console.error(e); - await cfn.sendResponse(event, - cfn.Status.FAILED, - event.PhysicalResourceId || context.logStreamName, - { SecretArn: '' }, - e.message); - } -} +exports.handler = cfn.customResourceHandler(handleEvent); -interface ResourceAttributes { +interface ResourceAttributes extends cfn.ResourceAttributes { SecretArn: string; - SecretVersionId?: string; - ParameterName?: string; - - [name: string]: string | undefined; + ParameterName: string; } -async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { +async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { const props = event.ResourceProperties; let newKey = event.RequestType === cfn.RequestType.CREATE; @@ -61,6 +42,7 @@ async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { const passPhrase = crypto.randomBytes(32).toString('base64'); - const tempDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'OpenPGP-')); + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'OpenPGP-')); try { process.env.GNUPGHOME = tempDir; const keyConfig = path.join(tempDir, 'key.config'); - await util.promisify(fs.writeFile)(keyConfig, [ + await writeFile(keyConfig, [ 'Key-Type: RSA', `Key-Length: ${event.ResourceProperties.KeySizeBits}`, `Name-Real: ${event.ResourceProperties.Identity}`, @@ -100,24 +82,29 @@ async function _createNewKey(event: cfn.CreateEvent | cfn.UpdateEvent, context: ? await secretsManager.createSecret({ ...secretOpts, Name: event.ResourceProperties.SecretName }).promise() : await secretsManager.updateSecret({ ...secretOpts, SecretId: event.PhysicalResourceId }).promise(); await ssm.putParameter({ - Description: `Public part of OpenPGP key ${secret.ARN} (version ${secret.VersionId})`, + Description: `Public part of OpenPGP key ${secret.ARN}`, Name: event.ResourceProperties.ParameterName, Overwrite: event.RequestType === 'Update', Type: 'String', Value: publicKey, }).promise(); - return { SecretArn: secret.ARN!, SecretVersionId: secret.VersionId!, ParameterName: event.ResourceProperties.ParameterName }; + return { + Ref: secret.ARN!, + SecretArn: secret.ARN!, + ParameterName: event.ResourceProperties.ParameterName + }; } finally { await _rmrf(tempDir); } } -async function _deleteSecret(event: cfn.DeleteEvent): Promise { - if (!event.PhysicalResourceId.startsWith('arn:')) { return { SecretArn: '' }; } - await ssm.deleteParameter({ Name: event.ResourceProperties.ParameterName }).promise(); - await secretsManager.deleteSecret({ SecretId: event.PhysicalResourceId }).promise(); - return { SecretArn: '' }; +async function _deleteSecret(event: cfn.DeleteEvent): Promise { + if (event.PhysicalResourceId.startsWith('arn:')) { + await ssm.deleteParameter({ Name: event.ResourceProperties.ParameterName }).promise(); + await secretsManager.deleteSecret({ SecretId: event.PhysicalResourceId }).promise(); + } + return { Ref: event.PhysicalResourceId }; } async function _updateExistingKey(event: cfn.UpdateEvent, context: lambda.Context): Promise { @@ -127,9 +114,10 @@ async function _updateExistingKey(event: cfn.UpdateEvent, context: lambda.Contex KmsKeyId: event.ResourceProperties.KeyArn, SecretId: event.PhysicalResourceId, }).promise(); + return { + Ref: result.ARN!, SecretArn: result.ARN!, - SecretVersionId: result.VersionId || await resolveCurrentVersionId(result.ARN!, secretsManager), ParameterName: event.ResourceProperties.ParameterName }; } diff --git a/custom-resource-handlers/src/private-key.ts b/custom-resource-handlers/src/private-key.ts index 8cbeac0e..c15d16ed 100644 --- a/custom-resource-handlers/src/private-key.ts +++ b/custom-resource-handlers/src/private-key.ts @@ -8,31 +8,15 @@ import cfn = require('./_cloud-formation'); import _exec = require('./_exec'); import lambda = require('./_lambda'); import _rmrf = require('./_rmrf'); -import { resolveCurrentVersionId } from './_secrets-manager'; + +const mkdtemp = util.promisify(fs.mkdtemp); +const readFile = util.promisify(fs.readFile); const secretsManager = new aws.SecretsManager(); -export async function main(event: cfn.Event, context: lambda.Context): Promise { - try { - // tslint:disable-next-line:no-console - console.log(`Input event: ${JSON.stringify(event)}`); - const attributes = await handleEvent(event, context); - await cfn.sendResponse(event, - cfn.Status.SUCCESS, - attributes.SecretArn || event.PhysicalResourceId || context.logStreamName, - attributes); - } catch (e) { - // tslint:disable-next-line:no-console - console.error(e); - await cfn.sendResponse(event, - cfn.Status.FAILED, - event.PhysicalResourceId || context.logStreamName, - { SecretArn: '' }, - e.message); - } -} +exports.handler = cfn.customResourceHandler(handleEvent); -async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { +async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { switch (event.RequestType) { case cfn.RequestType.CREATE: return await _createSecret(event, context); @@ -43,15 +27,12 @@ async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { - const tmpDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'x509PrivateKey-')); + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'x509PrivateKey-')); try { const pkeyFile = path.join(tmpDir, 'private_key.pem'); await _exec('openssl', 'genrsa', '-out', pkeyFile, event.ResourceProperties.KeySize); @@ -60,21 +41,22 @@ async function _createSecret(event: cfn.CreateEvent, context: lambda.Context): P Description: event.ResourceProperties.Description, KmsKeyId: event.ResourceProperties.KmsKeyId, Name: event.ResourceProperties.SecretName, - SecretString: await util.promisify(fs.readFile)(pkeyFile, { encoding: 'utf8' }), + SecretString: await readFile(pkeyFile, { encoding: 'utf8' }), }).promise(); - return { SecretArn: result.ARN!, SecretVersionId: result.VersionId! }; + return { + Ref: result.ARN!, + SecretArn: result.ARN!, + }; } finally { _rmrf(tmpDir); } } -async function _deleteSecret(event: cfn.DeleteEvent): Promise { +async function _deleteSecret(event: cfn.DeleteEvent): Promise { if (event.PhysicalResourceId.startsWith('arn:')) { - await secretsManager.deleteSecret({ - SecretId: event.PhysicalResourceId, - }).promise(); + await secretsManager.deleteSecret({ SecretId: event.PhysicalResourceId }).promise(); } - return { SecretArn: '', SecretVersionId: '' }; + return { Ref: event.PhysicalResourceId }; } async function _updateSecret(event: cfn.UpdateEvent, context: lambda.Context): Promise { @@ -91,5 +73,9 @@ async function _updateSecret(event: cfn.UpdateEvent, context: lambda.Context): P KmsKeyId: props.KmsKeyId, SecretId: event.PhysicalResourceId, }).promise(); - return { SecretArn: result.ARN!, SecretVersionId: result.VersionId || await resolveCurrentVersionId(result.ARN!, secretsManager) }; + + return { + Ref: result.ARN!, + SecretArn: result.ARN!, + }; } diff --git a/lib/code-signing/certificate-signing-request.ts b/lib/code-signing/certificate-signing-request.ts index b062c19c..defe6324 100644 --- a/lib/code-signing/certificate-signing-request.ts +++ b/lib/code-signing/certificate-signing-request.ts @@ -53,7 +53,7 @@ export class CertificateSigningRequest extends cdk.Construct { lambdaPurpose: 'CreateCSR', description: 'Creates a Certificate Signing Request document for an x509 certificate', runtime: lambda.Runtime.NodeJS810, - handler: 'index.main', + handler: 'index.handler', code: new lambda.AssetCode(codeLocation), timeout: 300, }); diff --git a/lib/code-signing/code-signing-certificate.ts b/lib/code-signing/code-signing-certificate.ts index 33f3a03f..3321bdb4 100644 --- a/lib/code-signing/code-signing-certificate.ts +++ b/lib/code-signing/code-signing-certificate.ts @@ -81,22 +81,22 @@ export class CodeSigningCertificate extends cdk.Construct implements ICodeSignin /** * The ARN of the AWS Secrets Manager secret that holds the private key for this CSC */ - public readonly secretArn: string; + public readonly privatePartSecretArn: string; /** * The ID of the version of the AWS Secrets Manager secret that holds the private key for this CSC */ - public readonly secretVersionId: string; + public readonly privatePartSecretVersionId: string; /** * The ARN of the AWS SSM Parameter that holds the certificate for this CSC. */ - public readonly parameterArn: string; + public readonly publicPartParameterArn: string; /** * The name of the AWS SSM parameter that holds the certificate for this CSC. */ - public readonly parameterName: string; + public readonly publicPartParameterName: string; /** * KMS key to encrypt the secret. @@ -123,8 +123,8 @@ export class CodeSigningCertificate extends cdk.Construct implements ICodeSignin this.secretEncryptionKey = props.secretEncryptionKey; - this.secretArn = privateKey.secretArn; - this.secretVersionId = privateKey.secretVersion; + this.privatePartSecretArn = privateKey.secretArn; + this.privatePartSecretVersionId = privateKey.secretVersion; let certificate = props.pemCertificate; @@ -146,16 +146,16 @@ export class CodeSigningCertificate extends cdk.Construct implements ICodeSignin } const paramName = `${baseName}/Certificate`; - this.parameterName = `/${paramName}`; + this.publicPartParameterName = `/${paramName}`; new ssm.cloudformation.ParameterResource(this, 'Resource', { description: `A PEM-encoded Code-Signing Certificate (private key in ${privateKey.secretArn} version ${privateKey.secretVersion})`, - name: this.parameterName, + name: this.publicPartParameterName, type: 'String', value: certificate }); - this.parameterArn = cdk.ArnUtils.fromComponents({ + this.publicPartParameterArn = cdk.ArnUtils.fromComponents({ service: 'ssm', resource: 'parameter', resourceName: paramName @@ -171,11 +171,11 @@ export class CodeSigningCertificate extends cdk.Construct implements ICodeSignin permissions.grantSecretRead({ keyArn: this.secretEncryptionKey && this.secretEncryptionKey.keyArn, - secretArn: this.secretArn, + secretArn: this.privatePartSecretArn, }, principal); principal.addToPolicy(new iam.PolicyStatement() .addAction('ssm:GetParameter') - .addResource(this.parameterArn)); + .addResource(this.publicPartParameterArn)); } } diff --git a/lib/code-signing/private-key.ts b/lib/code-signing/private-key.ts index ba2a6bc2..00fdd0f7 100644 --- a/lib/code-signing/private-key.ts +++ b/lib/code-signing/private-key.ts @@ -68,7 +68,7 @@ export class RsaPrivateKeySecret extends cdk.Construct { uuid: '72FD327D-3813-4632-9340-28EC437AA486', description: 'Generates an RSA Private Key and stores it in AWS Secrets Manager', runtime: lambda.Runtime.NodeJS810, - handler: 'index.main', + handler: 'index.handler', code: new lambda.AssetCode(codeLocation), timeout: 300, }); @@ -77,6 +77,7 @@ export class RsaPrivateKeySecret extends cdk.Construct { service: 'secretsmanager', resource: 'secret', sep: ':', + // The ARN of a secret has "-" followed by 6 random characters appended at the end resourceName: `${props.secretName}-??????` }); customResource.addToRolePolicy(new iam.PolicyStatement() diff --git a/lib/credential-pair.ts b/lib/credential-pair.ts index c9c39e9c..7a2cc47d 100644 --- a/lib/credential-pair.ts +++ b/lib/credential-pair.ts @@ -13,23 +13,23 @@ export interface ICredentialPair { * The ARN of the SSM parameter containing the public part of this credential * pair. */ - readonly parameterArn: string; + readonly publicPartParameterArn: string; /** * The name of the SSM parameter containing the public part of this credential * pair. */ - readonly parameterName: string; + readonly publicPartParameterName: string; /** * The ARN of the AWS SecretsManager secret that holds the private part of * this credential pair. */ - readonly secretArn: string; + readonly privatePartSecretArn: string; /** * The VersionId of the AWS SecretsManager secret that holds the private part * of this credential pair. */ - readonly secretVersionId: string; + readonly privatePartSecretVersionId: string; } diff --git a/lib/pgp-secret.ts b/lib/pgp-secret.ts index a06baf8a..27ea34e0 100644 --- a/lib/pgp-secret.ts +++ b/lib/pgp-secret.ts @@ -66,10 +66,10 @@ interface PGPSecretProps { * { "PrivateKey": "... ASCII repr of key...", "Passphrase": "passphrase of the key" } */ export class PGPSecret extends cdk.Construct implements ICredentialPair { - public readonly parameterArn: string; - public readonly parameterName: string; - public readonly secretArn: string; - public readonly secretVersionId: string; + public readonly publicPartParameterArn: string; + public readonly publicPartParameterName: string; + public readonly privatePartSecretArn: string; + public readonly privatePartSecretVersionId: string; constructor(parent: cdk.Construct, name: string, props: PGPSecretProps) { super(parent, name); @@ -81,7 +81,7 @@ export class PGPSecret extends cdk.Construct implements ICredentialPair { uuid: 'f25803d3-054b-44fc-985f-4860d7d6ee74', description: 'Generates an OpenPGP Key and stores the private key in Secrets Manager and the public key in an SSM Parameter', code: new lambda.AssetCode(codeLocation), - handler: 'index.main', + handler: 'index.handler', timeout: 300, runtime: lambda.Runtime.NodeJS810, initialPolicy: [ @@ -116,9 +116,9 @@ export class PGPSecret extends cdk.Construct implements ICredentialPair { description: props.description, }, }); - this.secretArn = secret.getAtt('SecretArn').toString(); - this.secretVersionId = secret.getAtt('SecretVersionId').toString(); - this.parameterName = secret.getAtt('ParameterName').toString(); - this.parameterArn = cdk.ArnUtils.fromComponents({ service: 'ssm', resource: 'parameter', resourceName: this.parameterName }); + this.privatePartSecretArn = secret.getAtt('SecretArn').toString(); + this.privatePartSecretVersionId = secret.getAtt('SecretVersionId').toString(); + this.publicPartParameterName = secret.getAtt('ParameterName').toString(); + this.publicPartParameterArn = cdk.ArnUtils.fromComponents({ service: 'ssm', resource: 'parameter', resourceName: this.publicPartParameterName }); } } diff --git a/lib/publishing.ts b/lib/publishing.ts index 7a8b19e1..c66714db 100644 --- a/lib/publishing.ts +++ b/lib/publishing.ts @@ -156,8 +156,8 @@ export class PublishToNuGetProject extends cdk.Construct implements IPublisher { env.NUGET_SECRET_ID = { value: props.nugetApiKeySecret.secretArn }; if (props.codeSign) { - env.CODE_SIGNING_SECRET_ID = { value: props.codeSign.secretArn }; - env.CODE_SIGNING_PARAMETER_NAME = { value: props.codeSign.parameterName }; + env.CODE_SIGNING_SECRET_ID = { value: props.codeSign.privatePartSecretArn }; + env.CODE_SIGNING_PARAMETER_NAME = { value: props.codeSign.publicPartParameterName }; } const shellable = new Shellable(this, 'Default', { diff --git a/lib/signing-key.ts b/lib/signing-key.ts index d19d75b1..3a617afd 100644 --- a/lib/signing-key.ts +++ b/lib/signing-key.ts @@ -66,7 +66,7 @@ export class OpenPgpKey extends cdk.Construct { public grantRead(identity: iam.IPrincipal) { // Secret grant, identity-based only identity.addToPolicy(new iam.PolicyStatement() - .addResources(this.secret.secretArn) + .addResources(this.secret.privatePartSecretArn) .addActions('secretsmanager:ListSecrets', 'secretsmanager:DescribeSecret', 'secretsmanager:GetSecretValue')); // Key grant diff --git a/package.json b/package.json index 25f7877c..8bea930c 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "jest": { "collectCoverage": true, "coverageDirectory": "./coverage", - "coverageReporters": ["lcov"] + "coverageReporters": ["lcov"], + "testEnvironment": "node" } } diff --git a/test/custom-resource-handlers/_cloud-formation.test.ts b/test/custom-resource-handlers/_cloud-formation.test.ts index d4319003..84a0cb1f 100644 --- a/test/custom-resource-handlers/_cloud-formation.test.ts +++ b/test/custom-resource-handlers/_cloud-formation.test.ts @@ -36,6 +36,10 @@ test('sends the correct response to CloudFormation', () => { emitter.on(evt, callback); return this; }, + once(evt: string, callback: (...args: any[]) => void) { + emitter.once(evt, callback); + return this; + }, write(str: string) { payload = str; return this; @@ -75,6 +79,10 @@ test('fails if the PUT request returns non-200', () => { emitter.on(evt, callback); return this; }, + once(evt: string, callback: (...args: any[]) => void) { + emitter.once(evt, callback); + return this; + }, write(str: string) { payload = str; return this; diff --git a/test/custom-resource-handlers/_rmrf.test.ts b/test/custom-resource-handlers/_rmrf.test.ts index a2a150f4..a00f5f20 100644 --- a/test/custom-resource-handlers/_rmrf.test.ts +++ b/test/custom-resource-handlers/_rmrf.test.ts @@ -4,7 +4,7 @@ import path = require('path'); import _rmrf = require('../../custom-resource-handlers/src/_rmrf'); -test('resmoves a full directory', () => { +test('removes a full directory', () => { const dir = fs.mkdtempSync(os.tmpdir()); fs.writeFileSync(path.join(dir, 'exhibit-A'), 'Exhibit A'); return expect(_rmrf(dir).then(() => fs.existsSync(dir))).resolves.toBeFalsy(); diff --git a/test/custom-resource-handlers/_secrets-manager.test.ts b/test/custom-resource-handlers/_secrets-manager.test.ts deleted file mode 100644 index 81a834e2..00000000 --- a/test/custom-resource-handlers/_secrets-manager.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import aws = require('aws-sdk'); -import { resolveCurrentVersionId } from '../../custom-resource-handlers/src/_secrets-manager'; - -test('resolves to the correct VersionId', async () => { - const secretId = "Sekr37"; - const versionId = "Shiney-VersionId"; - - const client = new aws.SecretsManager(); - client.listSecretVersionIds = jest.fn() - .mockName('secretsManager.listSecretVersionIds') - .mockImplementationOnce((opts: aws.SecretsManager.ListSecretVersionIdsRequest) => { - expect(opts.NextToken).toBe(undefined); - return { - promise() { - return Promise.resolve({ - Versions: [ - { VersionId: 'Version1', VersionStages: ['OLDVERSION', 'BUGGYVERSION'] } - ], - NextToken: '1' - }); - } - }; - }) - .mockImplementationOnce((opts: aws.SecretsManager.ListSecretVersionIdsRequest) => { - expect(opts.NextToken).toBe('1'); - return { - promise() { - return Promise.resolve({ - Versions: [ - { VersionId: versionId, VersionStages: ['NEWVERSION', 'AWSCURRENT', 'IDONTCARE'] } - ], - NextToken: undefined - }); - } - }; - }); - - return expect(await resolveCurrentVersionId(secretId, client)).toBe(versionId); -}); - -test('throws if there is no AWSCURRENT version', () => { - const secretId = "Sekr37"; - - const client = new aws.SecretsManager(); - client.listSecretVersionIds = jest.fn() - .mockName('secretsManager.listSecretVersionIds') - .mockImplementationOnce((opts: aws.SecretsManager.ListSecretVersionIdsRequest) => { - expect(opts.NextToken).toBe(undefined); - return { - promise() { - return Promise.resolve({ - Versions: [ - { VersionId: 'Version1', VersionStages: ['OLDVERSION', 'BUGGYVERSION'] } - ], - NextToken: '1' - }); - } - }; - }) - .mockImplementationOnce((opts: aws.SecretsManager.ListSecretVersionIdsRequest) => { - expect(opts.NextToken).toBe('1'); - return { - promise() { - return Promise.resolve({ - Versions: [ - { VersionId: 'Version2', VersionStages: ['NEWVERSION', 'IDONTCARE'] } - ], - NextToken: undefined - }); - } - }; - }); - - return expect(resolveCurrentVersionId(secretId, client)) - .rejects.toThrow(`Unable to determine the current VersionId of ${secretId}`); -}); diff --git a/test/custom-resource-handlers/certificate-signing-request.test.ts b/test/custom-resource-handlers/certificate-signing-request.test.ts index bddcaa69..8f1b7d57 100644 --- a/test/custom-resource-handlers/certificate-signing-request.test.ts +++ b/test/custom-resource-handlers/certificate-signing-request.test.ts @@ -77,7 +77,7 @@ jest.mock('../../custom-resource-handlers/src/_rmrf', () => mockRmrf); const mockRmrf = jest.fn().mockName('_rmrf') .mockResolvedValue(undefined); jest.mock('../../custom-resource-handlers/src/_rmrf', () => mockRmrf); -cfn.sendResponse = jest.fn().mockName('cfn.sendResponse').mockResolvedValue(undefined); +jest.spyOn(cfn, 'sendResponse').mockName('cfn.sendResponse').mockResolvedValue(undefined); beforeEach(() => jest.clearAllMocks()); @@ -110,8 +110,8 @@ test('Create', async () => { return ''; }); - const { main } = require('../../custom-resource-handlers/src/certificate-signing-request'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/certificate-signing-request'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockWriteFile) .toBeCalledWith(path.join(mockTmpDir, 'csr.config'), @@ -128,7 +128,7 @@ test('Create', async () => { .toBeCalledWith(event, cfn.Status.SUCCESS, event.LogicalResourceId, - { CSR: mockCsr, SelfSignedCertificate: mockCertificate }); + { Ref: event.LogicalResourceId, CSR: mockCsr, SelfSignedCertificate: mockCertificate }); }); test('Update', async () => { @@ -161,8 +161,8 @@ test('Update', async () => { return ''; }); - const { main } = require('../../custom-resource-handlers/src/certificate-signing-request'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/certificate-signing-request'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockWriteFile) .toBeCalledWith(path.join(mockTmpDir, 'csr.config'), @@ -179,7 +179,7 @@ test('Update', async () => { .toBeCalledWith(event, cfn.Status.SUCCESS, event.LogicalResourceId, - { CSR: mockCsr, SelfSignedCertificate: mockCertificate }); + { Ref: event.LogicalResourceId, CSR: mockCsr, SelfSignedCertificate: mockCertificate }); }); test('Delete', async () => { @@ -189,12 +189,12 @@ test('Delete', async () => { ...eventBase, }; - const { main } = require('../../custom-resource-handlers/src/certificate-signing-request'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/certificate-signing-request'); + await expect(handler(event, context)).resolves.toBe(undefined); return expect(cfn.sendResponse) .toBeCalledWith(event, cfn.Status.SUCCESS, event.LogicalResourceId, - { CSR: '', SelfSignedCertificate: '' }); + { Ref: event.LogicalResourceId }); }); diff --git a/test/custom-resource-handlers/pgp-secret.test.ts b/test/custom-resource-handlers/pgp-secret.test.ts index 1e302c3c..1ee0a6f3 100644 --- a/test/custom-resource-handlers/pgp-secret.test.ts +++ b/test/custom-resource-handlers/pgp-secret.test.ts @@ -5,7 +5,6 @@ import { createMockInstance } from 'jest-create-mock-instance'; import path = require('path'); import cfn = require('../../custom-resource-handlers/src/_cloud-formation'); import lambda = require('../../custom-resource-handlers/src/_lambda'); -import secretsManager = require('../../custom-resource-handlers/src/_secrets-manager'); const context: lambda.Context = { awsRequestId: 'E3802D69-27F8-44F0-9E4C-3329A8736A4C' } as any; const mockTmpDir = '/tmp/directory/is/phony'; @@ -30,7 +29,6 @@ const mockEventBase = { }; const secretArn = 'arn::::::secret'; -const secretVersionId = 'secret-version-id'; const passphrase = crypto.randomBytes(32); @@ -79,12 +77,12 @@ test('Create', async () => { }; mockSecretsManager.createSecret = jest.fn().mockName('SecretsManager.createSecret') - .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn, VersionId: secretVersionId}) })) as any; + .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn, VersionId: 'Secret-VersionId'}) })) as any; mockSSM.putParameter = jest.fn().mockName('SSM.putParameter') .mockImplementation(() => ({ promise: () => Promise.resolve({}) })) as any; - const { main } = require('../../custom-resource-handlers/src/pgp-secret'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/pgp-secret'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(writeFile) .toBeCalledWith(path.join(mockTmpDir, 'key.config'), @@ -103,7 +101,7 @@ test('Create', async () => { }); await expect(mockSSM.putParameter) .toBeCalledWith({ - Description: `Public part of OpenPGP key ${secretArn} (version ${secretVersionId})`, + Description: `Public part of OpenPGP key ${secretArn}`, Name: event.ResourceProperties.ParameterName, Overwrite: false, Type: 'String', @@ -114,8 +112,8 @@ test('Create', async () => { cfn.Status.SUCCESS, secretArn, { + Ref: secretArn, SecretArn: secretArn, - SecretVersionId: secretVersionId, ParameterName: event.ResourceProperties.ParameterName, }); }); @@ -134,11 +132,9 @@ test('Update', async () => { mockSecretsManager.updateSecret = jest.fn().mockName('SecretsManager.updateSecret') .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn }) })) as any; - secretsManager.resolveCurrentVersionId = jest.fn().mockName('resolveCurrentVersionId') - .mockResolvedValue(secretVersionId); - const { main } = require('../../custom-resource-handlers/src/pgp-secret'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/pgp-secret'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockSecretsManager.updateSecret) .toBeCalledWith({ ClientRequestToken: context.awsRequestId, @@ -150,7 +146,11 @@ test('Update', async () => { .toBeCalledWith(event, cfn.Status.SUCCESS, secretArn, - { SecretArn: secretArn, SecretVersionId: secretVersionId, ParameterName: event.ResourceProperties.ParameterName }); + { + Ref: secretArn, + SecretArn: secretArn, + ParameterName: event.ResourceProperties.ParameterName, + }); }); test('Delete', async () => { @@ -165,8 +165,8 @@ test('Delete', async () => { mockSSM.deleteParameter = jest.fn().mockName('SSM.deleteParameter') .mockImplementation(() => ({ promise: () => Promise.resolve({}) })) as any; - const { main } = require('../../custom-resource-handlers/src/pgp-secret'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/pgp-secret'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockSecretsManager.deleteSecret) .toBeCalledWith({ SecretId: secretArn }); await expect(mockSSM.deleteParameter) @@ -175,5 +175,5 @@ test('Delete', async () => { .toBeCalledWith(event, cfn.Status.SUCCESS, event.PhysicalResourceId, - { SecretArn: '' }); + { Ref: event.PhysicalResourceId }); }); diff --git a/test/custom-resource-handlers/private-key.test.ts b/test/custom-resource-handlers/private-key.test.ts index 39ec7117..4658adb1 100644 --- a/test/custom-resource-handlers/private-key.test.ts +++ b/test/custom-resource-handlers/private-key.test.ts @@ -3,7 +3,6 @@ import fs = require('fs'); import { createMockInstance } from 'jest-create-mock-instance'; import cfn = require('../../custom-resource-handlers/src/_cloud-formation'); import lambda = require('../../custom-resource-handlers/src/_lambda'); -import secretsManager = require('../../custom-resource-handlers/src/_secrets-manager'); const context: lambda.Context = { awsRequestId: '90E99AAE-B120-409A-9156-0C5925FDD996' } as lambda.Context; const mockKeySize = 4_096; @@ -23,7 +22,6 @@ const eventBase = { const mockTmpDir = '/tmp/directory/is/phony'; const mockPrivateKey = 'Phony PEM-Encoded Private Key'; const secretArn = 'arn::::::secret'; -const secretVersionId = 'secret-version-id'; cfn.sendResponse = jest.fn().mockName('cfn.sendResponse').mockResolvedValue(undefined); jest.mock('../../custom-resource-handlers/src/_exec', () => async (cmd: string, ...args: string[]) => { @@ -42,15 +40,13 @@ jest.spyOn(fs, 'readFile').mockName('fs.readFile') const mockSecretsManager = createMockInstance(aws.SecretsManager); jest.spyOn(aws, 'SecretsManager').mockImplementation(() => mockSecretsManager); mockSecretsManager.createSecret = jest.fn().mockName('SecretsManager.createSecret') - .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn, VersionId: secretVersionId }) })) as any; + .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn, VersionId: 'Secret-VersionID' }) })) as any; mockSecretsManager.updateSecret = jest.fn().mockName('SecretsManager.updateSecret') .mockImplementation(() => ({ promise: () => Promise.resolve({ ARN: secretArn }) })) as any; mockSecretsManager.deleteSecret = jest.fn().mockName('SecretsManager.deleteSecret') .mockImplementation(() => ({ promise: () => Promise.resolve({}) })) as any; const mockRmrf = jest.fn().mockName('_rmrf').mockResolvedValue(undefined); jest.mock('../../custom-resource-handlers/src/_rmrf', () => mockRmrf); -secretsManager.resolveCurrentVersionId = jest.fn().mockName('resolveCurrentVersionId') - .mockResolvedValue(secretVersionId); beforeEach(() => jest.clearAllMocks()); @@ -61,8 +57,8 @@ test('Create', async () => { ...eventBase, }; - const { main } = require('../../custom-resource-handlers/src/private-key'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/private-key'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockSecretsManager.createSecret) .toBeCalledWith({ @@ -79,7 +75,7 @@ test('Create', async () => { .toBeCalledWith(event, cfn.Status.SUCCESS, secretArn, - { SecretArn: secretArn, SecretVersionId: secretVersionId }); + { Ref: secretArn, SecretArn: secretArn }); }); test('Update (changing KeySize)', async () => { @@ -93,8 +89,8 @@ test('Update (changing KeySize)', async () => { ...eventBase, }; - const { main } = require('../../custom-resource-handlers/src/private-key'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/private-key'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockSecretsManager.createSecret).not.toBeCalled(); await expect(mockSecretsManager.updateSecret).not.toBeCalled(); @@ -103,23 +99,23 @@ test('Update (changing KeySize)', async () => { .toBeCalledWith(event, cfn.Status.FAILED, secretArn, - { SecretArn: '' }, + {}, expect.stringContaining('The KeySize property cannot be updated')); }); -test('Update (changing KeySize)', async () => { +test('Update (changing SecretName)', async () => { const event: cfn.Event = { RequestType: cfn.RequestType.UPDATE, PhysicalResourceId: secretArn, OldResourceProperties: { ...eventBase.ResourceProperties, - KeySize: mockKeySize * 2, + SecretName: 'Old/Secret/Name', }, ...eventBase, }; - const { main } = require('../../custom-resource-handlers/src/private-key'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/private-key'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockSecretsManager.createSecret).not.toBeCalled(); await expect(mockSecretsManager.updateSecret).not.toBeCalled(); @@ -128,8 +124,8 @@ test('Update (changing KeySize)', async () => { .toBeCalledWith(event, cfn.Status.FAILED, secretArn, - { SecretArn: '' }, - expect.stringContaining('The KeySize property cannot be updated')); + {}, + expect.stringContaining('The SecretName property cannot be updated')); }); test('Update (changing Description and KmsKeyId)', async () => { @@ -144,8 +140,8 @@ test('Update (changing Description and KmsKeyId)', async () => { ...eventBase, }; - const { main } = require('../../custom-resource-handlers/src/private-key'); - await expect(main(event, context)).resolves.toBe(undefined); + const { handler } = require('../../custom-resource-handlers/src/private-key'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockSecretsManager.createSecret).not.toBeCalled(); await expect(mockSecretsManager.updateSecret) @@ -160,7 +156,7 @@ test('Update (changing Description and KmsKeyId)', async () => { .toBeCalledWith(event, cfn.Status.SUCCESS, secretArn, - { SecretArn: secretArn, SecretVersionId: secretVersionId }); + { Ref: secretArn, SecretArn: secretArn }); }); test('Delete', async () => { @@ -170,8 +166,16 @@ test('Delete', async () => { ...eventBase, }; - const { main } = require('../../custom-resource-handlers/src/private-key'); - await expect(main(event, context)).resolves.toBe(undefined); + jest.spyOn(cfn, 'customResourceHandler').mockName('cfn.customResourceHandler') + .mockImplementation(async (cb) => { + const result = await cb(); + await expect(result).toEqual({ + Ref: event.PhysicalResourceId, + }); + }); + + const { handler } = require('../../custom-resource-handlers/src/private-key'); + await expect(handler(event, context)).resolves.toBe(undefined); await expect(mockSecretsManager.createSecret).not.toBeCalled(); await expect(mockSecretsManager.updateSecret).not.toBeCalled(); @@ -181,5 +185,5 @@ test('Delete', async () => { .toBeCalledWith(event, cfn.Status.SUCCESS, event.PhysicalResourceId, - { SecretArn: '', SecretVersionId: '' }); + { Ref: event.PhysicalResourceId }); }); diff --git a/test/expected.json b/test/expected.json index 0bcc2d8d..5dc1880d 100644 --- a/test/expected.json +++ b/test/expected.json @@ -2032,7 +2032,7 @@ Resources: Fn::GetAtt: - SingletonLambda72FD327D38134632934028EC437AA4865ADA6EFF - Arn - ResourceVersion: 3z1iEo2gliu6HO/ThFS47cru9WTvGrAk6ZwXVRGAnD8= + ResourceVersion: D814CuqbbboiuJN5/X3Z+0jTDSRIV+YFxOduP5eJEmo= Description: The PEM-encoded private key of the x509 Code-Signing Certificate KeySize: 2048 SecretName: delivlib-test/X509CodeSigningKey/RSAPrivateKey @@ -2049,7 +2049,7 @@ Resources: Fn::GetAtt: - CreateCSR541F67826DCF49A78C5A67715ADD9E4C8F4169F6 - Arn - ResourceVersion: WYxqU7hWKjmXHFvu6uy4NwfQVFZeU8PP67aSOgW1XVo= + ResourceVersion: I2WXTF1ul/g/BWPxr7MIHTYwK9pt3eiKV0H2BMZ5yRc= PrivateKeySecretId: Fn::GetAtt: - X509CodeSigningKeyRSAPrivateKeyE5980A70 @@ -2159,7 +2159,7 @@ Resources: - Fn::Split: - "||" - Ref: SingletonLambda72FD327D38134632934028EC437AA486CodeS3VersionKeyF016F0F7 - Handler: index.main + Handler: index.handler Role: Fn::GetAtt: - SingletonLambda72FD327D38134632934028EC437AA486ServiceRole225F46F5 @@ -2226,7 +2226,7 @@ Resources: - Fn::Split: - "||" - Ref: CreateCSR541F67826DCF49A78C5A67715ADD9E4CCodeS3VersionKey9D63F460 - Handler: index.main + Handler: index.handler Role: Fn::GetAtt: - CreateCSR541F67826DCF49A78C5A67715ADD9E4CServiceRoleD2990C92 @@ -2317,7 +2317,7 @@ Resources: Fn::GetAtt: - SingletonLambdaf25803d3054b44fc985f4860d7d6ee746203BDE6 - Arn - ResourceVersion: F2nr5wuNNEBZ3229VtPce9lV6s688L1KF9/x1ItEQ+U= + ResourceVersion: +SXY24n+9oQ12YNf59IL/WUOen+tUEeaWL3+I/totzo= Identity: aws-cdk-dev Email: aws-cdk-dev+delivlib@amazon.com Expiry: 4y @@ -2397,7 +2397,7 @@ Resources: - Fn::Split: - "||" - Ref: SingletonLambdaf25803d3054b44fc985f4860d7d6ee74CodeS3VersionKey10D2DDF5 - Handler: index.main + Handler: index.handler Role: Fn::GetAtt: - SingletonLambdaf25803d3054b44fc985f4860d7d6ee74ServiceRole410148CF diff --git a/test/pgp-secret.test.ts b/test/pgp-secret.test.ts index f2f9aa0c..26e68b76 100644 --- a/test/pgp-secret.test.ts +++ b/test/pgp-secret.test.ts @@ -50,5 +50,5 @@ test('correctly forwards parameter name', () => { }); // THEN - expect(cdk.resolve(secret.parameterName)).toEqual({ "Fn::GetAtt": ["SecretA720EF05", "ParameterName"] }); + expect(cdk.resolve(secret.publicPartParameterName)).toEqual({ "Fn::GetAtt": ["SecretA720EF05", "ParameterName"] }); }); From 1244691d629c33ab5ad3b416f9c65eda178c507b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Thu, 27 Dec 2018 13:58:24 +0100 Subject: [PATCH 8/9] Validate presence of required parameters --- .../src/_cloud-formation.ts | 20 +++++++++++++++++++ .../src/certificate-signing-request.ts | 14 +++++++++++++ custom-resource-handlers/src/pgp-secret.ts | 15 ++++++++++++++ custom-resource-handlers/src/private-key.ts | 9 +++++++++ 4 files changed, 58 insertions(+) diff --git a/custom-resource-handlers/src/_cloud-formation.ts b/custom-resource-handlers/src/_cloud-formation.ts index b97c7ec1..da505f80 100644 --- a/custom-resource-handlers/src/_cloud-formation.ts +++ b/custom-resource-handlers/src/_cloud-formation.ts @@ -135,3 +135,23 @@ export interface DeleteEvent extends CloudFormationEventBase { readonly RequestType: RequestType.DELETE; readonly PhysicalResourceId: string; } + +/** + * Validates that all required properties are present, and that no extraneous properties are provided. + * + * @param props the properties to be validated. + * @param validProps a mapping of valid property names to a boolean instructing whether the property is required or not. + */ +export function validateProperties(props: { [name: string]: any }, validProps: { [name: string]: boolean }) { + for (const property of Object.keys(props)) { + if (!(property in validProps)) { + throw new Error(`Unexpected property: ${property}`); + } + } + for (const property of Object.keys(validProps)) { + if (validProps[property] && !(property in props)) { + throw new Error(`Missing required property: ${property}`); + } + } + return props; +} diff --git a/custom-resource-handlers/src/certificate-signing-request.ts b/custom-resource-handlers/src/certificate-signing-request.ts index bcd99f2a..ae0ab7b8 100644 --- a/custom-resource-handlers/src/certificate-signing-request.ts +++ b/custom-resource-handlers/src/certificate-signing-request.ts @@ -23,6 +23,20 @@ interface ResourceAttributes extends cfn.ResourceAttributes { } async function handleEvent(event: cfn.Event, _context: lambda.Context): Promise { + if (event.RequestType !== cfn.RequestType.DELETE) { + cfn.validateProperties(event.ResourceProperties, { + DnCommonName: true, + DnCountry: true, + DnEmailAddress: true, + DnLocality: true, + DnOrganizationName: true, + DnOrganizationalUnitName: true, + DnStateOrProvince: true, + ExtendedKeyUsage: false, + KeyUsage: true, + }); + } + switch (event.RequestType) { case cfn.RequestType.CREATE: case cfn.RequestType.UPDATE: diff --git a/custom-resource-handlers/src/pgp-secret.ts b/custom-resource-handlers/src/pgp-secret.ts index 1c6f444f..6c4a3b70 100644 --- a/custom-resource-handlers/src/pgp-secret.ts +++ b/custom-resource-handlers/src/pgp-secret.ts @@ -25,6 +25,21 @@ interface ResourceAttributes extends cfn.ResourceAttributes { async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { const props = event.ResourceProperties; + + if (event.RequestType !== cfn.RequestType.DELETE) { + cfn.validateProperties(props, { + Description: false, + Email: true, + Expiry: true, + Identity: true, + KeyArn: false, + KeySizeBits: true, + ParameterName: true, + SecretName: true, + Version: false, + }); + } + let newKey = event.RequestType === cfn.RequestType.CREATE; if (event.RequestType === 'Update') { diff --git a/custom-resource-handlers/src/private-key.ts b/custom-resource-handlers/src/private-key.ts index c15d16ed..1d5eaf12 100644 --- a/custom-resource-handlers/src/private-key.ts +++ b/custom-resource-handlers/src/private-key.ts @@ -17,6 +17,15 @@ const secretsManager = new aws.SecretsManager(); exports.handler = cfn.customResourceHandler(handleEvent); async function handleEvent(event: cfn.Event, context: lambda.Context): Promise { + if (event.RequestType !== cfn.RequestType.DELETE) { + cfn.validateProperties(event.ResourceProperties, { + Description: false, + KeySize: true, + KmsKeyId: false, + SecretName: true, + }); + } + switch (event.RequestType) { case cfn.RequestType.CREATE: return await _createSecret(event, context); From bcc0ce285f2a6a13a6bc998a42836a278047721e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Thu, 27 Dec 2018 13:59:16 +0100 Subject: [PATCH 9/9] Use constant --- custom-resource-handlers/src/pgp-secret.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom-resource-handlers/src/pgp-secret.ts b/custom-resource-handlers/src/pgp-secret.ts index 6c4a3b70..f33af6ac 100644 --- a/custom-resource-handlers/src/pgp-secret.ts +++ b/custom-resource-handlers/src/pgp-secret.ts @@ -42,7 +42,7 @@ async function handleEvent(event: cfn.Event, context: lambda.Context): Promise