diff --git a/docs/src/tools.rst b/docs/src/tools.rst index 77c60c257b349..c18fba36d43b3 100644 --- a/docs/src/tools.rst +++ b/docs/src/tools.rst @@ -46,7 +46,7 @@ Here are the actions you can take on your CDK app .. code-block:: sh Usage: cdk -a COMMAND - + Commands: list Lists all stacks in the app [aliases: ls] synthesize [STACKS..] Synthesizes and prints the CloudFormation template @@ -64,7 +64,7 @@ Here are the actions you can take on your CDK app used. docs Opens the documentation in a browser[aliases: doc] doctor Check your set-up for potential problems - + Options: --app, -a REQUIRED: Command-line for executing your CDK app (e.g. "node bin/my-app.js") [string] @@ -90,12 +90,43 @@ Here are the actions you can take on your CDK app --role-arn, -r ARN of Role to use when invoking CloudFormation [string] --version Show version number [boolean] --help Show help [boolean] - + If your app has a single stack, there is no need to specify the stack name - + If one of cdk.json or ~/.cdk.json exists, options specified there will be used as defaults. Settings in cdk.json take precedence. +.. _security-changes: + +Security-related changes +======================== + +In order to protect you against unintended changes that affect your security posture, +the CDK toolkit will prompt you to approve security-related changes before deploying +them. + +You change the level of changes that requires approval by specifying: + +.. code-block:: + + cdk deploy --require-approval LEVEL + +Where ``LEVEL`` can be one of: + +* ``never`` - approval is never required. +* ``any-change`` - require approval on any IAM or security-group related change. +* ``broadening`` (default) - require approval when IAM statements or traffic rules are added. Removals + do not require approval. + +The setting also be configured in **cdk.json**: + +.. code-block:: js + + { + "app": "...", + "requireApproval": "never" + } + .. _version-reporting: Version Reporting diff --git a/packages/@aws-cdk/cfnspec/build-tools/build.ts b/packages/@aws-cdk/cfnspec/build-tools/build.ts index 1657b8ca34859..424fdb3d44183 100644 --- a/packages/@aws-cdk/cfnspec/build-tools/build.ts +++ b/packages/@aws-cdk/cfnspec/build-tools/build.ts @@ -10,6 +10,7 @@ import fs = require('fs-extra'); import md5 = require('md5'); import path = require('path'); import { schema } from '../lib'; +import { detectScrutinyTypes } from './scrutiny'; async function main() { const inputDir = path.join(process.cwd(), 'spec-source'); @@ -25,6 +26,8 @@ async function main() { } } + detectScrutinyTypes(spec); + spec.Fingerprint = md5(JSON.stringify(normalize(spec))); const outDir = path.join(process.cwd(), 'spec'); diff --git a/packages/@aws-cdk/cfnspec/build-tools/scrutiny.ts b/packages/@aws-cdk/cfnspec/build-tools/scrutiny.ts new file mode 100644 index 0000000000000..48cd3102ef528 --- /dev/null +++ b/packages/@aws-cdk/cfnspec/build-tools/scrutiny.ts @@ -0,0 +1,89 @@ +import { schema } from '../lib'; +import { PropertyScrutinyType, ResourceScrutinyType } from '../lib/schema'; + +/** + * Auto-detect common properties to apply scrutiny to by using heuristics + * + * Manually enhancing scrutiny attributes for each property does not scale + * well. Fortunately, the most important ones follow a common naming scheme and + * we tag all of them at once in this way. + * + * If the heuristic scheme gets it wrong in some individual cases, those can be + * fixed using schema patches. + */ +export function detectScrutinyTypes(spec: schema.Specification) { + for (const [typeName, typeSpec] of Object.entries(spec.ResourceTypes)) { + if (typeSpec.ScrutinyType !== undefined) { continue; } // Already assigned + + detectResourceScrutiny(typeName, typeSpec); + + // If a resource scrutiny is set by now, we don't need to look at the properties anymore + if (typeSpec.ScrutinyType !== undefined) { continue; } + + for (const [propertyName, propertySpec] of Object.entries(typeSpec.Properties || {})) { + if (propertySpec.ScrutinyType !== undefined) { continue; } // Already assigned + + detectPropertyScrutiny(typeName, propertyName, propertySpec); + + } + } +} + +/** + * Detect and assign a scrutiny type for the resource + */ +function detectResourceScrutiny(typeName: string, typeSpec: schema.ResourceType) { + const properties = Object.entries(typeSpec.Properties || {}); + + // If this resource is named like *Policy and has a PolicyDocument property + if (typeName.endsWith('Policy') && properties.some(apply2(isPolicyDocumentProperty))) { + typeSpec.ScrutinyType = isIamType(typeName) ? ResourceScrutinyType.IdentityPolicyResource : ResourceScrutinyType.ResourcePolicyResource; + return; + } +} + +/** + * Detect and assign a scrutiny type for the property + */ +function detectPropertyScrutiny(_typeName: string, propertyName: string, propertySpec: schema.Property) { + // Detect fields named like ManagedPolicyArns + if (propertyName === 'ManagedPolicyArns') { + propertySpec.ScrutinyType = PropertyScrutinyType.ManagedPolicies; + return; + } + + if (propertyName === "Policies" && schema.isComplexListProperty(propertySpec) && propertySpec.ItemType === 'Policy') { + propertySpec.ScrutinyType = PropertyScrutinyType.InlineIdentityPolicies; + return; + } + + if (isPolicyDocumentProperty(propertyName, propertySpec)) { + propertySpec.ScrutinyType = PropertyScrutinyType.InlineResourcePolicy; + return; + } +} + +function isIamType(typeName: string) { + return typeName.indexOf('::IAM::') > 1; +} + +function isPolicyDocumentProperty(propertyName: string, propertySpec: schema.Property) { + const nameContainsPolicy = propertyName.indexOf('Policy') > -1; + const primitiveType = schema.isPrimitiveProperty(propertySpec) && propertySpec.PrimitiveType; + + if (nameContainsPolicy && primitiveType === 'Json') { + return true; + } + return false; +} + +/** + * Make a function that takes 2 arguments take an array of 2 elements instead + * + * Makes it possible to map it over an array of arrays. TypeScript won't allow + * me to overload this type declaration so we need a different function for + * every # of arguments. + */ +function apply2(fn: (a1: T1, a2: T2) => R): (as: [T1, T2]) => R { + return (as) => fn.apply(fn, as); +} \ No newline at end of file diff --git a/packages/@aws-cdk/cfnspec/lib/index.ts b/packages/@aws-cdk/cfnspec/lib/index.ts index 61e3fb726f919..7037cb29c7594 100644 --- a/packages/@aws-cdk/cfnspec/lib/index.ts +++ b/packages/@aws-cdk/cfnspec/lib/index.ts @@ -5,18 +5,48 @@ export { schema }; /** * The complete AWS CloudFormation Resource specification, having any CDK patches and enhancements included in it. */ -// tslint:disable-next-line:no-var-requires -export const specification: schema.Specification = require('../spec/specification.json'); +export function specification(): schema.Specification { + return require('../spec/specification.json'); +} + +/** + * Return the resource specification for the given typename + * + * Validates that the resource exists. If you don't want this validating behavior, read from + * specification() directly. + */ +export function resourceSpecification(typeName: string): schema.ResourceType { + const ret = specification().ResourceTypes[typeName]; + if (!ret) { + throw new Error(`No such resource type: ${typeName}`); + } + return ret; +} + +/** + * Return the property specification for the given resource's property + */ +export function propertySpecification(typeName: string, propertyName: string): schema.Property { + const ret = resourceSpecification(typeName).Properties![propertyName]; + if (!ret) { + throw new Error(`Resource ${typeName} has no property: ${propertyName}`); + } + return ret; +} /** * The list of resource type names defined in the ``specification``. */ -export const resourceTypes = Object.keys(specification.ResourceTypes); +export function resourceTypes() { + return Object.keys(specification().ResourceTypes); +} /** * The list of namespaces defined in the ``specification``, that is resource name prefixes down to the second ``::``. */ -export const namespaces = Array.from(new Set(resourceTypes.map(n => n.split('::', 2).join('::')))); +export function namespaces() { + return Array.from(new Set(resourceTypes().map(n => n.split('::', 2).join('::')))); +} /** * Obtain a filtered version of the AWS CloudFormation specification. @@ -30,14 +60,16 @@ export const namespaces = Array.from(new Set(resourceTypes.map(n => n.split('::' * to the selected resource types. */ export function filteredSpecification(filter: string | RegExp | Filter): schema.Specification { - const result: schema.Specification = { ResourceTypes: {}, PropertyTypes: {}, Fingerprint: specification.Fingerprint }; + const spec = specification(); + + const result: schema.Specification = { ResourceTypes: {}, PropertyTypes: {}, Fingerprint: spec.Fingerprint }; const predicate: Filter = makePredicate(filter); - for (const type of resourceTypes) { + for (const type of resourceTypes()) { if (!predicate(type)) { continue; } - result.ResourceTypes[type] = specification.ResourceTypes[type]; + result.ResourceTypes[type] = spec.ResourceTypes[type]; const prefix = `${type}.`; - for (const propType of Object.keys(specification.PropertyTypes!).filter(n => n.startsWith(prefix))) { - result.PropertyTypes[propType] = specification.PropertyTypes![propType]; + for (const propType of Object.keys(spec.PropertyTypes!).filter(n => n.startsWith(prefix))) { + result.PropertyTypes[propType] = spec.PropertyTypes![propType]; } } result.Fingerprint = crypto.createHash('sha256').update(JSON.stringify(result)).digest('base64'); @@ -64,3 +96,35 @@ function makePredicate(filter: string | RegExp | Filter): Filter { return s => s.match(filter) != null; } } + +/** + * Return the properties of the given type that require the given scrutiny type + */ +export function scrutinizablePropertyNames(resourceType: string, scrutinyTypes: schema.PropertyScrutinyType[]): string[] { + const impl = specification().ResourceTypes[resourceType]; + if (!impl) { return []; } + + const ret = new Array(); + + for (const [propertyName, propertySpec] of Object.entries(impl.Properties || {})) { + if (scrutinyTypes.includes(propertySpec.ScrutinyType || schema.PropertyScrutinyType.None)) { + ret.push(propertyName); + } + } + + return ret; +} + +/** + * Return the names of the resource types that need to be subjected to additional scrutiny + */ +export function scrutinizableResourceTypes(scrutinyTypes: schema.ResourceScrutinyType[]): string[] { + const ret = new Array(); + for (const [resourceType, resourceSpec] of Object.entries(specification().ResourceTypes)) { + if (scrutinyTypes.includes(resourceSpec.ScrutinyType || schema.ResourceScrutinyType.None)) { + ret.push(resourceType); + } + } + + return ret; +} diff --git a/packages/@aws-cdk/cfnspec/lib/schema/property.ts b/packages/@aws-cdk/cfnspec/lib/schema/property.ts index b9679d9ac98b3..16dbad3a016e5 100644 --- a/packages/@aws-cdk/cfnspec/lib/schema/property.ts +++ b/packages/@aws-cdk/cfnspec/lib/schema/property.ts @@ -16,6 +16,13 @@ export interface PropertyBase extends Documented { * example, which other properties you updated. */ UpdateType: UpdateType; + + /** + * During a stack update, what kind of additional scrutiny changes to this property should be subjected to + * + * @default None + */ + ScrutinyType?: PropertyScrutinyType; } export interface PrimitiveProperty extends PropertyBase { @@ -154,3 +161,39 @@ export function isUnionProperty(prop: Property): prop is UnionProperty { const castProp = prop as UnionProperty; return !!(castProp.ItemTypes || castProp.PrimitiveTypes || castProp.Types); } + +export enum PropertyScrutinyType { + /** + * No additional scrutiny + */ + None = 'None', + + /** + * This is an IAM policy directly on a resource + */ + InlineResourcePolicy = 'InlineResourcePolicy', + + /** + * Either an AssumeRolePolicyDocument or a dictionary of policy documents + */ + InlineIdentityPolicies = 'InlineIdentityPolicies', + + /** + * A list of managed policies (on an identity resource) + */ + ManagedPolicies = 'ManagedPolicies', + + /** + * A set of ingress rules (on a security group) + */ + IngressRules = 'IngressRules', + + /** + * A set of egress rules (on a security group) + */ + EgressRules = 'EgressRules', +} + +export function isPropertyScrutinyType(str: string): str is PropertyScrutinyType { + return (PropertyScrutinyType as any)[str] !== undefined; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cfnspec/lib/schema/resource-type.ts b/packages/@aws-cdk/cfnspec/lib/schema/resource-type.ts index 7517fcdbe1bf8..1b00979d72cd6 100644 --- a/packages/@aws-cdk/cfnspec/lib/schema/resource-type.ts +++ b/packages/@aws-cdk/cfnspec/lib/schema/resource-type.ts @@ -19,6 +19,13 @@ export interface ResourceType extends Documented { * What kind of value the 'Ref' operator refers to, if any. */ RefKind?: string; + + /** + * During a stack update, what kind of additional scrutiny changes to this resource should be subjected to + * + * @default None + */ + ScrutinyType?: ResourceScrutinyType; } export type Attribute = PrimitiveAttribute | ListAttribute; @@ -71,3 +78,43 @@ export enum SpecialRefKind { */ Arn = 'Arn' } + +export enum ResourceScrutinyType { + /** + * No additional scrutiny + */ + None = 'None', + + /** + * An externally attached policy document to a resource + * + * (Common for SQS, SNS, S3, ...) + */ + ResourcePolicyResource = 'ResourcePolicyResource', + + /** + * This is an IAM policy on an identity resource + * + * (Basically saying: this is AWS::IAM::Policy) + */ + IdentityPolicyResource = 'IdentityPolicyResource', + + /** + * This is a Lambda Permission policy + */ + LambdaPermission = 'LambdaPermission', + + /** + * An ingress rule object + */ + IngressRuleResource = 'IngressRuleResource', + + /** + * A set of egress rules + */ + EgressRuleResource = 'EgressRuleResource', +} + +export function isResourceScrutinyType(str: string): str is ResourceScrutinyType { + return (ResourceScrutinyType as any)[str] !== undefined; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cfnspec/spec-source/200_Scrutinies_patch.json b/packages/@aws-cdk/cfnspec/spec-source/200_Scrutinies_patch.json new file mode 100644 index 0000000000000..6f860d718ddda --- /dev/null +++ b/packages/@aws-cdk/cfnspec/spec-source/200_Scrutinies_patch.json @@ -0,0 +1,86 @@ +{ + "ResourceTypes": { + "AWS::Lambda::Permission": { + "patch": { + "description": "Permission scrutiny", + "operations": [ + { + "op": "add", + "path": "/ScrutinyType", + "value": "LambdaPermission" + } + ] + } + }, + "AWS::SNS::Subscription": { + "patch": { + "description": "SNS: These are not IAM policies", + "operations": [ + { + "op": "add", + "path": "/Properties/DeliveryPolicy/ScrutinyType", + "value": "None" + }, + { + "op": "add", + "path": "/Properties/FilterPolicy/ScrutinyType", + "value": "None" + } + ] + } + }, + "AWS::SQS::Queue": { + "patch": { + "description": "SQS: Not an IAM policy", + "operations": [ + { + "op": "add", + "path": "/Properties/RedrivePolicy/ScrutinyType", + "value": "None" + } + ] + } + }, + "AWS::EC2::SecurityGroup": { + "patch": { + "description": "SecurityGroup: Mark ingress/egress rules", + "operations": [ + { + "op": "add", + "path": "/Properties/SecurityGroupIngress/ScrutinyType", + "value": "IngressRules" + }, + { + "op": "add", + "path": "/Properties/SecurityGroupEgress/ScrutinyType", + "value": "EgressRules" + } + ] + } + }, + "AWS::EC2::SecurityGroupIngress": { + "patch": { + "description": "SecurityGroupIngress: Mark ingress rules", + "operations": [ + { + "op": "add", + "path": "/ScrutinyType", + "value": "IngressRuleResource" + } + ] + } + }, + "AWS::EC2::SecurityGroupEgress": { + "patch": { + "description": "SecurityGroupEgress: Mark egress rules", + "operations": [ + { + "op": "add", + "path": "/ScrutinyType", + "value": "EgressRuleResource" + } + ] + } + } + } +} diff --git a/packages/@aws-cdk/cfnspec/test/spec-validators.ts b/packages/@aws-cdk/cfnspec/test/spec-validators.ts index 3c0116afcce73..35e0cf29b12d1 100644 --- a/packages/@aws-cdk/cfnspec/test/spec-validators.ts +++ b/packages/@aws-cdk/cfnspec/test/spec-validators.ts @@ -12,6 +12,9 @@ function validateResourceTypes(test: Test, specification: Specification) { test.ok(typeName, 'Resource type name is not empty'); const type = specification.ResourceTypes[typeName]; test.notEqual(type.Documentation, null, `${typeName} is documented`); + if (type.ScrutinyType) { + test.ok(schema.isResourceScrutinyType(type.ScrutinyType), `${typeName}.ScrutinyType is not a valid ResourceScrutinyType`); + } if (type.Properties) { validateProperties(typeName, test, type.Properties, specification); } if (type.Attributes) { validateAttributes(typeName, test, type.Attributes, specification); } } @@ -30,64 +33,56 @@ function validateProperties(typeName: string, test: Test, properties: { [name: string]: schema.Property }, specification: Specification) { - const requiredKeys = ['Documentation', 'Required', 'UpdateType']; + const expectedKeys = ['Documentation', 'Required', 'UpdateType', 'ScrutinyType']; for (const name of Object.keys(properties)) { + const property = properties[name]; test.notEqual(property.Documentation, '', `${typeName}.Properties.${name} is documented`); test.ok(schema.isUpdateType(property.UpdateType), `${typeName}.Properties.${name} has valid UpdateType`); + if (property.ScrutinyType !== undefined) { + test.ok(schema.isPropertyScrutinyType(property.ScrutinyType), `${typeName}.Properties.${name} has valid ScrutinyType`); + } test.notEqual(property.Required, null, `${typeName}.Properties.${name} has required flag`); + if (schema.isPrimitiveProperty(property)) { - test.deepEqual(Object.keys(property).sort(), - [...requiredKeys, 'PrimitiveType'].sort(), - `${typeName}.Properties.${name} has no extra properties`); test.ok(schema.isPrimitiveType(property.PrimitiveType), `${typeName}.Properties.${name} has a valid PrimitiveType`); + expectedKeys.push('PrimitiveType'); + } else if (schema.isPrimitiveListProperty(property)) { - // The DuplicatesAllowed key is optional (absent === false) - const extraKeys = 'DuplicatesAllowed' in property ? ['DuplicatesAllowed'] : []; - test.deepEqual(Object.keys(property).sort(), - [...requiredKeys, ...extraKeys, 'PrimitiveItemType', 'Type'].sort(), - `${typeName}.Properties.${name} has no extra properties`); + expectedKeys.push('Type', 'DuplicatesAllowed', 'PrimitiveItemType'); test.ok(schema.isPrimitiveType(property.PrimitiveItemType), `${typeName}.Properties.${name} has a valid PrimitiveItemType`); + } else if (schema.isPrimitiveMapProperty(property)) { - // The DuplicatesAllowed key is optional (absent === false) - const extraKeys = 'DuplicatesAllowed' in property ? ['DuplicatesAllowed'] : []; - test.deepEqual(Object.keys(property).sort(), - [...requiredKeys, ...extraKeys, 'PrimitiveItemType', 'Type'].sort(), - `${typeName}.Properties.${name} has no extra properties`); + expectedKeys.push('Type', 'DuplicatesAllowed', 'PrimitiveItemType', 'Type'); test.ok(schema.isPrimitiveType(property.PrimitiveItemType), `${typeName}.Properties.${name} has a valid PrimitiveItemType`); test.ok(!property.DuplicatesAllowed, `${typeName}.Properties.${name} does not allow duplicates`); + } else if (schema.isComplexListProperty(property)) { - // The DuplicatesAllowed key is optional (absent === false) - const extraKeys = 'DuplicatesAllowed' in property ? ['DuplicatesAllowed'] : []; - test.deepEqual(Object.keys(property).sort(), - [...requiredKeys, ...extraKeys, 'ItemType', 'Type'].sort(), - `${typeName}.Properties.${name} has no extra properties`); + expectedKeys.push('Type', 'DuplicatesAllowed', 'ItemType', 'Type'); test.ok(property.ItemType, `${typeName}.Properties.${name} has a valid ItemType`); if (property.ItemType !== 'Tag') { const fqn = `${typeName.split('.')[0]}.${property.ItemType}`; const resolvedType = specification.PropertyTypes && specification.PropertyTypes[fqn]; test.ok(resolvedType, `${typeName}.Properties.${name} ItemType (${fqn}) resolves`); } + } else if (schema.isComplexMapProperty(property)) { - // The DuplicatesAllowed key is optional (absent === false) - const extraKeys = 'DuplicatesAllowed' in property ? ['DuplicatesAllowed'] : []; - test.deepEqual(Object.keys(property).sort(), - [...requiredKeys, ...extraKeys, 'ItemType', 'Type'].sort(), - `${typeName}.Properties.${name} has no extra properties`); + expectedKeys.push('Type', 'DuplicatesAllowed', 'ItemType', 'Type'); test.ok(property.ItemType, `${typeName}.Properties.${name} has a valid ItemType`); const fqn = `${typeName.split('.')[0]}.${property.ItemType}`; const resolvedType = specification.PropertyTypes && specification.PropertyTypes[fqn]; test.ok(resolvedType, `${typeName}.Properties.${name} ItemType (${fqn}) resolves`); test.ok(!property.DuplicatesAllowed, `${typeName}.Properties.${name} does not allow duplicates`); + } else if (schema.isComplexProperty(property)) { + expectedKeys.push('Type'); test.ok(property.Type, `${typeName}.Properties.${name} has a valid type`); const fqn = `${typeName.split('.')[0]}.${property.Type}`; const resolvedType = specification.PropertyTypes && specification.PropertyTypes[fqn]; test.ok(resolvedType, `${typeName}.Properties.${name} type (${fqn}) resolves`); - test.deepEqual(Object.keys(property).sort(), - [...requiredKeys, 'Type'].sort(), - `${typeName}.Properties.${name} has no extra properties`); + } else if (schema.isUnionProperty(property)) { + expectedKeys.push('PrimitiveTypes', 'PrimitiveItemTypes', 'ItemTypes', 'Types'); if (property.PrimitiveTypes) { for (const type of property.PrimitiveTypes) { test.ok(schema.isPrimitiveType(type), `${typeName}.Properties.${name} has only valid PrimitiveTypes`); @@ -107,9 +102,14 @@ function validateProperties(typeName: string, test.ok(resolvedType, `${typeName}.Properties.${name} type (${fqn}) resolves`); } } + } else { test.ok(false, `${typeName}.Properties.${name} has known type`); } + + test.deepEqual( + without(Object.keys(property), expectedKeys), [], + `${typeName}.Properties.${name} has no extra properties`); } } @@ -140,3 +140,20 @@ function validateAttributes(typeName: string, } } } + +/** + * Remove elements from a set + */ +function without(xs: T[], ...sets: T[][]) { + const ret = new Set(xs); + + for (const set of sets) { + for (const element of set) { + if (ret.has(element)) { + ret.delete(element); + } + } + } + + return Array.from(ret); +} \ No newline at end of file diff --git a/packages/@aws-cdk/cfnspec/test/test.filtered-specification.ts b/packages/@aws-cdk/cfnspec/test/test.filtered-specification.ts index b96fb5b244cef..1296a1bd2486c 100644 --- a/packages/@aws-cdk/cfnspec/test/test.filtered-specification.ts +++ b/packages/@aws-cdk/cfnspec/test/test.filtered-specification.ts @@ -17,7 +17,7 @@ const tests: any = { } }; -for (const name of resourceTypes.sort()) { +for (const name of resourceTypes().sort()) { tests[`filteredSpecification(${JSON.stringify(name)})`] = (test: Test) => { const filteredSpec = filteredSpecification(name); test.notDeepEqual(filteredSpec, specification, `The filteredSpecification result is not the whole specification`); diff --git a/packages/@aws-cdk/cfnspec/test/test.namespaces.ts b/packages/@aws-cdk/cfnspec/test/test.namespaces.ts index cd5d5076560fb..9136f518005f5 100644 --- a/packages/@aws-cdk/cfnspec/test/test.namespaces.ts +++ b/packages/@aws-cdk/cfnspec/test/test.namespaces.ts @@ -3,7 +3,7 @@ import { namespaces } from '../lib/index'; export = testCase({ 'expected namespaces are present'(test: Test) { - test.deepEqual(namespaces.sort(), expectedNamespaces.sort()); + test.deepEqual(namespaces().sort(), expectedNamespaces.sort()); test.done(); } }); diff --git a/packages/@aws-cdk/cfnspec/test/test.scrutiny.ts b/packages/@aws-cdk/cfnspec/test/test.scrutiny.ts new file mode 100644 index 0000000000000..91c3a364c0111 --- /dev/null +++ b/packages/@aws-cdk/cfnspec/test/test.scrutiny.ts @@ -0,0 +1,73 @@ +import { Test } from 'nodeunit'; +import { propertySpecification, resourceSpecification } from '../lib'; +import { PropertyScrutinyType, ResourceScrutinyType } from '../lib/schema'; + +export = { + 'spot-check IAM identity tags'(test: Test) { + const prop = propertySpecification('AWS::IAM::Role', 'Policies'); + test.equals(prop.ScrutinyType, PropertyScrutinyType.InlineIdentityPolicies); + + test.done(); + }, + + 'IAM AssumeRolePolicy'(test: Test) { + // AssumeRolePolicyDocument is a resource policy, because it applies to the Role itself! + const prop = propertySpecification('AWS::IAM::Role', 'AssumeRolePolicyDocument'); + test.equals(prop.ScrutinyType, PropertyScrutinyType.InlineResourcePolicy); + + test.done(); + }, + + 'spot-check IAM resource tags'(test: Test) { + const prop = propertySpecification('AWS::KMS::Key', 'KeyPolicy'); + test.equals(prop.ScrutinyType, PropertyScrutinyType.InlineResourcePolicy); + + test.done(); + }, + + 'spot-check resource policy resources'(test: Test) { + test.equals(resourceSpecification('AWS::S3::BucketPolicy').ScrutinyType, ResourceScrutinyType.ResourcePolicyResource); + + test.done(); + }, + + 'spot-check no misclassified tags'(test: Test) { + const prop = propertySpecification('AWS::SNS::Subscription', 'DeliveryPolicy'); + test.equals(prop.ScrutinyType, PropertyScrutinyType.None); + + test.done(); + }, + + 'check Lambda permission resource scrutiny'(test: Test) { + test.equals(resourceSpecification('AWS::Lambda::Permission').ScrutinyType, ResourceScrutinyType.LambdaPermission); + + test.done(); + }, + + 'check role managedpolicyarns'(test: Test) { + const prop = propertySpecification('AWS::IAM::Role', 'ManagedPolicyArns'); + test.equals(prop.ScrutinyType, PropertyScrutinyType.ManagedPolicies); + + test.done(); + }, + + 'check securityGroup scrutinies'(test: Test) { + const inProp = propertySpecification('AWS::EC2::SecurityGroup', 'SecurityGroupIngress'); + test.equals(inProp.ScrutinyType, PropertyScrutinyType.IngressRules); + + const eProp = propertySpecification('AWS::EC2::SecurityGroup', 'SecurityGroupEgress'); + test.equals(eProp.ScrutinyType, PropertyScrutinyType.EgressRules); + + test.done(); + }, + + 'check securityGroupRule scrutinies'(test: Test) { + const inRes = resourceSpecification('AWS::EC2::SecurityGroupIngress'); + test.equals(inRes.ScrutinyType, ResourceScrutinyType.IngressRuleResource); + + const eRes = resourceSpecification('AWS::EC2::SecurityGroupEgress'); + test.equals(eRes.ScrutinyType, ResourceScrutinyType.EgressRuleResource); + + test.done(); + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts index b8c744120fa27..0add877280471 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts @@ -26,43 +26,49 @@ export function diffParameter(oldValue: types.Parameter, newValue: types.Paramet return new types.ParameterDifference(oldValue, newValue); } -export function diffResource(oldValue: types.Resource, newValue: types.Resource): types.ResourceDifference { +export function diffResource(oldValue?: types.Resource, newValue?: types.Resource): types.ResourceDifference { const resourceType = { oldType: oldValue && oldValue.Type, newType: newValue && newValue.Type }; - let propertyChanges: { [key: string]: types.PropertyDifference } = {}; + let propertyUpdates: { [key: string]: types.PropertyDifference } = {}; let otherChanges: { [key: string]: types.Difference } = {}; - if (resourceType.oldType === resourceType.newType) { + if (resourceType.oldType !== undefined && resourceType.oldType === resourceType.newType) { // Only makes sense to inspect deeper if the types stayed the same const typeSpec = cfnspec.filteredSpecification(resourceType.oldType); const impl = typeSpec.ResourceTypes[resourceType.oldType]; - propertyChanges = diffKeyedEntities(oldValue.Properties, - newValue.Properties, + propertyUpdates = diffKeyedEntities(oldValue!.Properties, + newValue!.Properties, (oldVal, newVal, key) => _diffProperty(oldVal, newVal, key, impl)); otherChanges = diffKeyedEntities(oldValue, newValue, _diffOther); delete otherChanges.Properties; } - return new types.ResourceDifference(oldValue, newValue, { resourceType, propertyChanges, otherChanges }); + return new types.ResourceDifference(oldValue, newValue, { + resourceType, propertyUpdates, otherChanges, + oldProperties: oldValue && oldValue.Properties, + newProperties: newValue && newValue.Properties, + }); function _diffProperty(oldV: any, newV: any, key: string, resourceSpec?: cfnspec.schema.ResourceType) { - let changeImpact: types.ResourceImpact | undefined; + let changeImpact; + const spec = resourceSpec && resourceSpec.Properties && resourceSpec.Properties[key]; if (spec) { switch (spec.UpdateType) { - case 'Immutable': - changeImpact = types.ResourceImpact.WILL_REPLACE; - break; - case 'Conditional': - changeImpact = types.ResourceImpact.MAY_REPLACE; - break; - default: - // In those cases, whatever is the current value is what we should keep - changeImpact = types.ResourceImpact.WILL_UPDATE; + case 'Immutable': + changeImpact = types.ResourceImpact.WILL_REPLACE; + break; + case 'Conditional': + changeImpact = types.ResourceImpact.MAY_REPLACE; + break; + default: + // In those cases, whatever is the current value is what we should keep + changeImpact = types.ResourceImpact.WILL_UPDATE; } } + return new types.PropertyDifference(oldV, newV, { changeImpact }); } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 55b23bdb1321f..f222e56171ac3 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -1,6 +1,11 @@ +import cfnspec = require('@aws-cdk/cfnspec'); import { AssertionError } from 'assert'; +import { IamChanges } from '../iam/iam-changes'; +import { SecurityGroupChanges } from '../network/security-group-changes'; import { deepEqual } from './util'; +export type PropertyMap = {[key: string]: any }; + /** Semantic differences between two CloudFormation templates. */ export class TemplateDiff implements ITemplateDiff { public awsTemplateFormatVersion?: Difference; @@ -15,6 +20,16 @@ export class TemplateDiff implements ITemplateDiff { /** The differences in unknown/unexpected parts of the template */ public unknown: DifferenceCollection>; + /** + * Changes to IAM policies + */ + public readonly iamChanges: IamChanges; + + /** + * Changes to Security Group ingress and egress rules + */ + public readonly securityGroupChanges: SecurityGroupChanges; + constructor(args: ITemplateDiff) { if (args.awsTemplateFormatVersion !== undefined) { this.awsTemplateFormatVersion = args.awsTemplateFormatVersion; @@ -33,6 +48,18 @@ export class TemplateDiff implements ITemplateDiff { this.parameters = args.parameters || new DifferenceCollection({}); this.resources = args.resources || new DifferenceCollection({}); this.unknown = args.unknown || new DifferenceCollection({}); + + this.iamChanges = new IamChanges({ + propertyChanges: this.scrutinizablePropertyChanges(IamChanges.IamPropertyScrutinies), + resourceChanges: this.scrutinizableResourceChanges(IamChanges.IamResourceScrutinies), + }); + + this.securityGroupChanges = new SecurityGroupChanges({ + egressRulePropertyChanges: this.scrutinizablePropertyChanges([cfnspec.schema.PropertyScrutinyType.EgressRules]), + ingressRulePropertyChanges: this.scrutinizablePropertyChanges([cfnspec.schema.PropertyScrutinyType.IngressRules]), + egressRuleResourceChanges: this.scrutinizableResourceChanges([cfnspec.schema.ResourceScrutinyType.EgressRuleResource]), + ingressRuleResourceChanges: this.scrutinizableResourceChanges([cfnspec.schema.ResourceScrutinyType.IngressRuleResource]), + }); } public get count() { @@ -62,6 +89,172 @@ export class TemplateDiff implements ITemplateDiff { public get isEmpty(): boolean { return this.count === 0; } + + /** + * Return true if any of the permissions objects involve a broadening of permissions + */ + public get permissionsBroadened(): boolean { + return this.iamChanges.permissionsBroadened || this.securityGroupChanges.rulesAdded; + } + + /** + * Return true if any of the permissions objects have changed + */ + public get permissionsAnyChanges(): boolean { + return this.iamChanges.hasChanges || this.securityGroupChanges.hasChanges; + } + + /** + * Return all property changes of a given scrutiny type + * + * We don't just look at property updates; we also look at resource additions and deletions (in which + * case there is no further detail on property values), and resource type changes. + */ + private scrutinizablePropertyChanges(scrutinyTypes: cfnspec.schema.PropertyScrutinyType[]): PropertyChange[] { + const ret = new Array(); + + for (const [resourceLogicalId, resourceChange] of Object.entries(this.resources.changes)) { + if (!resourceChange) { continue; } + + const props = cfnspec.scrutinizablePropertyNames(resourceChange.newResourceType!, scrutinyTypes); + for (const propertyName of props) { + ret.push({ + resourceLogicalId, propertyName, + resourceType: resourceChange.resourceType, + scrutinyType: cfnspec.propertySpecification(resourceChange.resourceType, propertyName).ScrutinyType!, + oldValue: resourceChange.oldProperties && resourceChange.oldProperties[propertyName], + newValue: resourceChange.newProperties && resourceChange.newProperties[propertyName], + }); + } + } + + return ret; + } + + /** + * Return all resource changes of a given scrutiny type + * + * We don't just look at resource updates; we also look at resource additions and deletions (in which + * case there is no further detail on property values), and resource type changes. + */ + private scrutinizableResourceChanges(scrutinyTypes: cfnspec.schema.ResourceScrutinyType[]): ResourceChange[] { + const ret = new Array(); + + const scrutinizableTypes = new Set(cfnspec.scrutinizableResourceTypes(scrutinyTypes)); + + for (const [resourceLogicalId, resourceChange] of Object.entries(this.resources.changes)) { + if (!resourceChange) { continue; } + + const commonProps = { + oldProperties: resourceChange.oldProperties, + newProperties: resourceChange.newProperties, + resourceLogicalId + }; + + // Even though it's not physically possible in CFN, let's pretend to handle a change of 'Type'. + if (resourceChange.resourceTypeChanged) { + // Treat as DELETE+ADD + if (scrutinizableTypes.has(resourceChange.oldResourceType!)) { + ret.push({ + ...commonProps, + newProperties: undefined, + resourceType: resourceChange.oldResourceType!, + scrutinyType: cfnspec.resourceSpecification(resourceChange.oldResourceType!).ScrutinyType!, + }); + } + if (scrutinizableTypes.has(resourceChange.newResourceType!)) { + ret.push({ + ...commonProps, + oldProperties: undefined, + resourceType: resourceChange.newResourceType!, + scrutinyType: cfnspec.resourceSpecification(resourceChange.newResourceType!).ScrutinyType!, + }); + } + } else { + if (scrutinizableTypes.has(resourceChange.resourceType)) { + ret.push({ + ...commonProps, + resourceType: resourceChange.resourceType, + scrutinyType: cfnspec.resourceSpecification(resourceChange.resourceType).ScrutinyType!, + }); + } + } + } + + return ret; + } +} + +/** + * A change in property values + * + * Not necessarily an update, it could be that there used to be no value there + * because there was no resource, and now there is (or vice versa). + * + * Therefore, we just contain plain values and not a PropertyDifference. + */ +export interface PropertyChange { + /** + * Logical ID of the resource where this property change was found + */ + resourceLogicalId: string; + + /** + * Type of the resource + */ + resourceType: string; + + /** + * Scrutiny type for this property change + */ + scrutinyType: cfnspec.schema.PropertyScrutinyType; + + /** + * Name of the property that is changing + */ + propertyName: string; + + /** + * The old property value + */ + oldValue?: any; + + /** + * The new property value + */ + newValue?: any; +} + +/** + * A resource change + * + * Either a creation, deletion or update. + */ +export interface ResourceChange { + /** + * Logical ID of the resource where this property change was found + */ + resourceLogicalId: string; + + /** + * Scrutiny type for this resource change + */ + scrutinyType: cfnspec.schema.ResourceScrutinyType; + + /** + * The type of the resource + */ + resourceType: string; + + /** + * The old properties value (might be undefined in case of creation) + */ + oldProperties?: PropertyMap; + + /** + * The new properties value (might be undefined in case of deletion) + */ + newProperties?: PropertyMap; } /** @@ -259,42 +452,81 @@ export interface Resource { [key: string]: any; } + export class ResourceDifference extends Difference { + /** + * Old property values + */ + public readonly oldProperties?: PropertyMap; + + /** + * New property values + */ + public readonly newProperties?: PropertyMap; + /** Property-level changes on the resource */ - public readonly propertyChanges: { [key: string]: PropertyDifference }; + public readonly propertyUpdates: { [key: string]: PropertyDifference }; /** Changes to non-property level attributes of the resource */ public readonly otherChanges: { [key: string]: Difference }; /** The resource type (or old and new type if it has changed) */ - private readonly resourceType: { readonly oldType: string, readonly newType: string }; + private readonly resourceTypes: { readonly oldType?: string, readonly newType?: string }; constructor(oldValue: Resource | undefined, newValue: Resource | undefined, args: { - resourceType: { oldType: string, newType: string }, - propertyChanges: { [key: string]: Difference }, + resourceType: { oldType?: string, newType?: string }, + oldProperties?: PropertyMap, + newProperties?: PropertyMap, + propertyUpdates: { [key: string]: PropertyDifference }, otherChanges: { [key: string]: Difference } } ) { super(oldValue, newValue); - this.resourceType = args.resourceType; - this.propertyChanges = args.propertyChanges; + this.resourceTypes = args.resourceType; + this.propertyUpdates = args.propertyUpdates; this.otherChanges = args.otherChanges; + this.oldProperties = args.oldProperties; + this.newProperties = args.newProperties; } public get oldResourceType(): string | undefined { - return this.resourceType.oldType; + return this.resourceTypes.oldType; } public get newResourceType(): string | undefined { - return this.resourceType.newType; + return this.resourceTypes.newType; + } + + /** + * Return whether the resource type was changed in this diff + * + * This is not a valid operation in CloudFormation but to be defensive we're going + * to be aware of it anyway. + */ + public get resourceTypeChanged(): boolean { + return (this.resourceTypes.oldType !== undefined + && this.resourceTypes.newType !== undefined + && this.resourceTypes.oldType !== this.resourceTypes.newType); + } + + /** + * Return the resource type if it was unchanged + * + * If the resource type was changed, it's an error to call this. + */ + public get resourceType(): string { + if (this.resourceTypeChanged) { + throw new Error('Cannot get .resourceType, because the type was changed'); + } + return this.resourceTypes.oldType || this.resourceTypes.newType!; } public get changeImpact(): ResourceImpact { // Check the Type first - if (this.resourceType.oldType !== this.resourceType.newType) { - if (this.resourceType.oldType === undefined) { return ResourceImpact.WILL_CREATE; } - if (this.resourceType.newType === undefined) { + if (this.resourceTypes.oldType !== this.resourceTypes.newType) { + if (this.resourceTypes.oldType === undefined) { return ResourceImpact.WILL_CREATE; } + if (this.resourceTypes.newType === undefined) { return this.oldValue!.DeletionPolicy === 'Retain' ? ResourceImpact.WILL_ORPHAN : ResourceImpact.WILL_DESTROY; @@ -302,19 +534,19 @@ export class ResourceDifference extends Difference { return ResourceImpact.WILL_REPLACE; } - return Object.values(this.propertyChanges) + return Object.values(this.propertyUpdates) .map(elt => elt.changeImpact) .reduce(worstImpact, ResourceImpact.WILL_UPDATE); } public get count(): number { - return Object.keys(this.propertyChanges).length + return Object.keys(this.propertyUpdates).length + Object.keys(this.otherChanges).length; } public forEach(cb: (type: 'Property' | 'Other', name: string, value: Difference | PropertyDifference) => any) { - for (const key of Object.keys(this.propertyChanges).sort()) { - cb('Property', key, this.propertyChanges[key]); + for (const key of Object.keys(this.propertyUpdates).sort()) { + cb('Property', key, this.propertyUpdates[key]); } for (const key of Object.keys(this.otherChanges).sort()) { cb('Other', key, this.otherChanges[key]); diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diffable.ts b/packages/@aws-cdk/cloudformation-diff/lib/diffable.ts new file mode 100644 index 0000000000000..7bc87b51144ef --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/diffable.ts @@ -0,0 +1,56 @@ +/** + * Calculate differences of immutable elements + */ +export class DiffableCollection> { + public readonly additions: T[] = []; + public readonly removals: T[] = []; + + private readonly oldElements: T[] = []; + private readonly newElements: T[] = []; + + public addOld(...elements: T[]) { + this.oldElements.push(...elements); + } + + public addNew(...elements: T[]) { + this.newElements.push(...elements); + } + + public calculateDiff() { + this.additions.push(...difference(this.newElements, this.oldElements)); + this.removals.push(...difference(this.oldElements, this.newElements)); + } + + public get hasChanges() { + return this.additions.length + this.removals.length > 0; + } + + public get hasAdditions() { + return this.additions.length > 0; + } + + public get hasRemovals() { + return this.removals.length > 0; + } +} + +/** + * Things that can be compared to themselves (by value) + */ +interface Eq { + equal(other: T): boolean; +} + +/** + * Whether a collection contains some element (by value) + */ +function contains>(element: T, xs: T[]) { + return xs.some(x => x.equal(element)); +} + +/** + * Return collection except for elements + */ +function difference>(collection: T[], elements: T[]) { + return collection.filter(x => !contains(x, elements)); +} diff --git a/packages/@aws-cdk/cloudformation-diff/lib/format.ts b/packages/@aws-cdk/cloudformation-diff/lib/format.ts index 9a700e2004f3e..df88d075cc2f6 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/format.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/format.ts @@ -1,9 +1,12 @@ import cxapi = require('@aws-cdk/cx-api'); +import Table = require('cli-table'); import colors = require('colors/safe'); import { format } from 'util'; import { Difference, isPropertyDifference, ResourceDifference, ResourceImpact } from './diff-template'; import { DifferenceCollection, TemplateDiff } from './diff/types'; import { deepEqual } from './diff/util'; +import { IamChanges } from './iam/iam-changes'; +import { SecurityGroupChanges } from './network/security-group-changes'; /** * Renders template differences to the process' console. @@ -13,50 +16,86 @@ import { deepEqual } from './diff/util'; * case there is no aws:cdk:path metadata in the template. */ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: TemplateDiff, logicalToPathMap: { [logicalId: string]: string } = { }) { - function print(fmt: string, ...args: any[]) { - stream.write(colors.white(format(fmt, ...args)) + '\n'); + const formatter = new Formatter(stream, logicalToPathMap, templateDiff); + + if (templateDiff.awsTemplateFormatVersion || templateDiff.transform || templateDiff.description) { + formatter.printSectionHeader('Template'); + formatter.formatDifference('AWSTemplateFormatVersion', 'AWSTemplateFormatVersion', templateDiff.awsTemplateFormatVersion); + formatter.formatDifference('Transform', 'Transform', templateDiff.transform); + formatter.formatDifference('Description', 'Description', templateDiff.description); + formatter.printSectionFooter(); } - const ADDITION = colors.green('[+]'); const UPDATE = colors.yellow('[~]'); - const REMOVAL = colors.red('[-]'); + formatSecurityChangesWithBanner(formatter, templateDiff); - if (templateDiff.awsTemplateFormatVersion || templateDiff.transform || templateDiff.description) { - printSectionHeader('Template'); - formatDifference('AWSTemplateFormatVersion', 'AWSTemplateFormatVersion', templateDiff.awsTemplateFormatVersion); - formatDifference('Transform', 'Transform', templateDiff.transform); - formatDifference('Description', 'Description', templateDiff.description); - printSectionFooter(); + formatter.formatSection('Parameters', 'Parameter', templateDiff.parameters); + formatter.formatSection('Metadata', 'Metadata', templateDiff.metadata); + formatter.formatSection('Mappings', 'Mapping', templateDiff.mappings); + formatter.formatSection('Conditions', 'Condition', templateDiff.conditions); + formatter.formatSection('Resources', 'Resource', templateDiff.resources, formatter.formatResourceDifference.bind(formatter)); + formatter.formatSection('Outputs', 'Output', templateDiff.outputs); + formatter.formatSection('Other Changes', 'Unknown', templateDiff.unknown); +} + +/** + * Renders a diff of security changes to the given stream + */ +export function formatSecurityChanges(stream: NodeJS.WriteStream, templateDiff: TemplateDiff, logicalToPathMap: {[logicalId: string]: string} = {}) { + const formatter = new Formatter(stream, logicalToPathMap, templateDiff); + + formatSecurityChangesWithBanner(formatter, templateDiff); +} + +function formatSecurityChangesWithBanner(formatter: Formatter, templateDiff: TemplateDiff) { + if (!templateDiff.iamChanges.hasChanges && !templateDiff.securityGroupChanges.hasChanges) { return; } + formatter.formatIamChanges(templateDiff.iamChanges); + formatter.formatSecurityGroupChanges(templateDiff.securityGroupChanges); + + formatter.warning(`(NOTE: There may be security-related changes not in this list. See http://bit.ly/cdk-2EhF7Np)`); + formatter.printSectionFooter(); +} + +const ADDITION = colors.green('[+]'); +const UPDATE = colors.yellow('[~]'); +const REMOVAL = colors.red('[-]'); + +class Formatter { + constructor(private readonly stream: NodeJS.WriteStream, private readonly logicalToPathMap: { [logicalId: string]: string }, diff?: TemplateDiff) { + // Read additional construct paths from the diff if it is supplied + if (diff) { + this.readConstructPathsFrom(diff); + } + } + + public print(fmt: string, ...args: any[]) { + this.stream.write(colors.white(format(fmt, ...args)) + '\n'); } - formatSection('Parameters', 'Parameter', templateDiff.parameters); - formatSection('Metadata', 'Metadata', templateDiff.metadata); - formatSection('Mappings', 'Mapping', templateDiff.mappings); - formatSection('Conditions', 'Condition', templateDiff.conditions); - formatSection('Resources', 'Resource', templateDiff.resources, formatResourceDifference); - formatSection('Outputs', 'Output', templateDiff.outputs); - formatSection('Other Changes', 'Unknown', templateDiff.unknown); + public warning(fmt: string, ...args: any[]) { + this.stream.write(colors.yellow(format(fmt, ...args)) + '\n'); + } - function formatSection>( + public formatSection>( title: string, entryType: string, collection: DifferenceCollection, - formatter: (type: string, id: string, diff: T) => void = formatDifference) { + formatter: (type: string, id: string, diff: T) => void = this.formatDifference.bind(this)) { if (collection.count === 0) { return; } - printSectionHeader(title); + this.printSectionHeader(title); collection.forEach((id, diff) => formatter(entryType, id, diff)); - printSectionFooter(); + this.printSectionFooter(); } - function printSectionHeader(title: string) { - print(colors.underline(colors.bold(title))); + public printSectionHeader(title: string) { + this.print(colors.underline(colors.bold(title))); } - function printSectionFooter() { - print(''); + public printSectionFooter() { + this.print(''); } /** @@ -65,13 +104,13 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp * @param logicalId the name of the entity that is different. * @param diff the difference to be rendered. */ - function formatDifference(type: string, logicalId: string, diff: Difference | undefined) { + public formatDifference(type: string, logicalId: string, diff: Difference | undefined) { if (!diff) { return; } let value; - const oldValue = formatValue(diff.oldValue, colors.red); - const newValue = formatValue(diff.newValue, colors.green); + const oldValue = this.formatValue(diff.oldValue, colors.red); + const newValue = this.formatValue(diff.newValue, colors.green); if (diff.isAddition) { value = newValue; } else if (diff.isUpdate) { @@ -80,7 +119,7 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp value = oldValue; } - print(`${formatPrefix(diff)} ${colors.cyan(type)} ${formatLogicalId(logicalId)}: ${value}`); + this.print(`${this.formatPrefix(diff)} ${colors.cyan(type)} ${this.formatLogicalId(logicalId)}: ${value}`); } /** @@ -89,22 +128,22 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp * @param logicalId the logical ID of the resource that changed. * @param diff the change to be rendered. */ - function formatResourceDifference(_type: string, logicalId: string, diff: ResourceDifference) { + public formatResourceDifference(_type: string, logicalId: string, diff: ResourceDifference) { const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType; // tslint:disable-next-line:max-line-length - print(`${formatPrefix(diff)} ${formatValue(resourceType, colors.cyan)} ${formatLogicalId(logicalId, diff)} ${formatImpact(diff.changeImpact)}`); + this.print(`${this.formatPrefix(diff)} ${this.formatValue(resourceType, colors.cyan)} ${this.formatLogicalId(logicalId)} ${this.formatImpact(diff.changeImpact)}`); if (diff.isUpdate) { let processedCount = 0; diff.forEach((_, name, values) => { processedCount += 1; - formatTreeDiff(name, values, processedCount === diff.count); + this.formatTreeDiff(name, values, processedCount === diff.count); }); } } - function formatPrefix(diff: Difference) { + public formatPrefix(diff: Difference) { if (diff.isAddition) { return ADDITION; } if (diff.isUpdate) { return UPDATE; } if (diff.isRemoval) { return REMOVAL; } @@ -117,7 +156,7 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp * * @returns the formatted string, with color applied. */ - function formatValue(value: any, color: (str: string) => string) { + public formatValue(value: any, color: (str: string) => string) { if (value == null) { return undefined; } if (typeof value === 'string') { return color(value); } return color(JSON.stringify(value)); @@ -127,7 +166,7 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp * @param impact the impact to be formatted * @returns a user-friendly, colored string representing the impact. */ - function formatImpact(impact: ResourceImpact) { + public formatImpact(impact: ResourceImpact) { switch (impact) { case ResourceImpact.MAY_REPLACE: return colors.italic(colors.yellow('may be replaced')); @@ -149,7 +188,7 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp * @param diff the difference on the tree. * @param last whether this is the last node of a parent tree. */ - function formatTreeDiff(name: string, diff: Difference, last: boolean) { + public formatTreeDiff(name: string, diff: Difference, last: boolean) { let additionalInfo = ''; if (isPropertyDifference(diff)) { if (diff.changeImpact === ResourceImpact.MAY_REPLACE) { @@ -158,8 +197,8 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp additionalInfo = ' (requires replacement)'; } } - print(' %s─ %s %s%s', last ? '└' : '├', changeTag(diff.oldValue, diff.newValue), name, additionalInfo); - return formatObjectDiff(diff.oldValue, diff.newValue, ` ${last ? ' ' : '│'}`); + this.print(' %s─ %s %s%s', last ? '└' : '├', this.changeTag(diff.oldValue, diff.newValue), name, additionalInfo); + return this.formatObjectDiff(diff.oldValue, diff.newValue, ` ${last ? ' ' : '│'}`); } /** @@ -170,15 +209,15 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp * @param newObject the new object. * @param linePrefix a prefix (indent-like) to be used on every line. */ - function formatObjectDiff(oldObject: any, newObject: any, linePrefix: string) { + public formatObjectDiff(oldObject: any, newObject: any, linePrefix: string) { if ((typeof oldObject !== typeof newObject) || Array.isArray(oldObject) || typeof oldObject === 'string' || typeof oldObject === 'number') { if (oldObject !== undefined && newObject !== undefined) { - print('%s ├─ %s %s', linePrefix, REMOVAL, formatValue(oldObject, colors.red)); - print('%s └─ %s %s', linePrefix, ADDITION, formatValue(newObject, colors.green)); + this.print('%s ├─ %s %s', linePrefix, REMOVAL, this.formatValue(oldObject, colors.red)); + this.print('%s └─ %s %s', linePrefix, ADDITION, this.formatValue(newObject, colors.green)); } else if (oldObject !== undefined /* && newObject === undefined */) { - print('%s └─ %s', linePrefix, formatValue(oldObject, colors.red)); + this.print('%s └─ %s', linePrefix, this.formatValue(oldObject, colors.red)); } else /* if (oldObject === undefined && newObject !== undefined) */ { - print('%s └─ %s', linePrefix, formatValue(newObject, colors.green)); + this.print('%s └─ %s', linePrefix, this.formatValue(newObject, colors.green)); } return; } @@ -191,12 +230,12 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp const newValue = newObject[key]; const treePrefix = key === lastKey ? '└' : '├'; if (oldValue !== undefined && newValue !== undefined) { - print('%s %s─ %s %s:', linePrefix, treePrefix, changeTag(oldValue, newValue), colors.blue(`.${key}`)); - formatObjectDiff(oldValue, newValue, `${linePrefix} ${key === lastKey ? ' ' : '│'}`); + this.print('%s %s─ %s %s:', linePrefix, treePrefix, this.changeTag(oldValue, newValue), colors.blue(`.${key}`)); + this.formatObjectDiff(oldValue, newValue, `${linePrefix} ${key === lastKey ? ' ' : '│'}`); } else if (oldValue !== undefined /* && newValue === undefined */) { - print('%s %s─ %s Removed: %s', linePrefix, treePrefix, REMOVAL, colors.blue(`.${key}`)); + this.print('%s %s─ %s Removed: %s', linePrefix, treePrefix, REMOVAL, colors.blue(`.${key}`)); } else /* if (oldValue === undefined && newValue !== undefined */ { - print('%s %s─ %s Added: %s', linePrefix, treePrefix, ADDITION, colors.blue(`.${key}`)); + this.print('%s %s─ %s Added: %s', linePrefix, treePrefix, ADDITION, colors.blue(`.${key}`)); } } } @@ -208,7 +247,7 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp * @returns a tag to be rendered in the diff, reflecting whether the difference * was an ADDITION, UPDATE or REMOVAL. */ - function changeTag(oldValue: any | undefined, newValue: any | undefined): string { + public changeTag(oldValue: any | undefined, newValue: any | undefined): string { if (oldValue !== undefined && newValue !== undefined) { return UPDATE; } else if (oldValue !== undefined /* && newValue === undefined*/) { @@ -218,28 +257,43 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp } } - function formatLogicalId(logicalId: string, diff?: ResourceDifference) { - // if we have a path in the map, return it - const path = logicalToPathMap[logicalId]; - if (path) { - // first component of path is the stack name, so let's remove that - return normalizePath(path); - } + /** + * Find 'aws:cdk:path' metadata in the diff and add it to the logicalToPathMap + * + * There are multiple sources of logicalID -> path mappings: synth metadata + * and resource metadata, and we combine all sources into a single map. + */ + public readConstructPathsFrom(templateDiff: TemplateDiff) { + for (const [logicalId, resourceDiff] of Object.entries(templateDiff.resources)) { + if (!resourceDiff) { continue; } + + const oldPathMetadata = resourceDiff.oldValue && resourceDiff.oldValue.Metadata && resourceDiff.oldValue.Metadata[cxapi.PATH_METADATA_KEY]; + if (oldPathMetadata && !(logicalId in this.logicalToPathMap)) { + this.logicalToPathMap[logicalId] = oldPathMetadata; + } - // if we don't have in our map, it might be a deleted resource, so let's try the - // template metadata - const oldPathMetadata = diff && diff.oldValue && diff.oldValue.Metadata && diff.oldValue.Metadata[cxapi.PATH_METADATA_KEY]; - if (oldPathMetadata) { - return normalizePath(oldPathMetadata); + const newPathMetadata = resourceDiff.newValue && resourceDiff.newValue.Metadata && resourceDiff.newValue.Metadata[cxapi.PATH_METADATA_KEY]; + if (newPathMetadata && !(logicalId in this.logicalToPathMap)) { + this.logicalToPathMap[logicalId] = newPathMetadata; + } } + } + + public formatLogicalId(logicalId: string) { + // if we have a path in the map, return it + const normalized = this.normalizedLogicalIdPath(logicalId); - const newPathMetadata = diff && diff.newValue && diff.newValue.Metadata && diff.newValue.Metadata[cxapi.PATH_METADATA_KEY]; - if (newPathMetadata) { - return normalizePath(newPathMetadata); + if (normalized) { + return `${normalized} ${colors.gray(logicalId)}`; } - // couldn't figure out the path, just return the logical ID return logicalId; + } + + public normalizedLogicalIdPath(logicalId: string): string | undefined { + // if we have a path in the map, return it + const path = this.logicalToPathMap[logicalId]; + return path ? normalizePath(path) : undefined; /** * Path is supposed to start with "/stack-name". If this is the case (i.e. path has more than @@ -265,8 +319,82 @@ export function formatDifferences(stream: NodeJS.WriteStream, templateDiff: Temp p = parts.join('/'); } + return p; + } + } + + public formatIamChanges(changes: IamChanges) { + if (!changes.hasChanges) { return; } + + if (changes.statements.hasChanges) { + this.printSectionHeader('IAM Statement Changes'); + this.print(renderTable(this.deepSubstituteBracedLogicalIds(changes.summarizeStatements()))); + } - return `${p} ${colors.gray(logicalId)}`; + if (changes.managedPolicies.hasChanges) { + this.printSectionHeader('IAM Policy Changes'); + this.print(renderTable(this.deepSubstituteBracedLogicalIds(changes.summarizeManagedPolicies()))); } } + + public formatSecurityGroupChanges(changes: SecurityGroupChanges) { + if (!changes.hasChanges) { return; } + + this.printSectionHeader('Security Group Changes'); + this.print(renderTable(this.deepSubstituteBracedLogicalIds(changes.summarize()))); + } + + public deepSubstituteBracedLogicalIds(rows: string[][]): string[][] { + return rows.map(row => row.map(this.substituteBracedLogicalIds.bind(this))); + } + + /** + * Substitute all strings like ${LogId.xxx} with the path instead of the logical ID + */ + public substituteBracedLogicalIds(source: string): string { + return source.replace(/\$\{([^.}]+)(.[^}]+)?\}/ig, (_match, logId, suffix) => { + return '${' + (this.normalizedLogicalIdPath(logId) || logId) + (suffix || '') + '}'; + }); + } +} + +/** + * Render a two-dimensional array to a visually attractive table + * + * First row is considered the table header. + */ +function renderTable(cells: string[][]): string { + const head = cells.splice(0, 1)[0]; + + const table = new Table({ head, style: { head: [] } }); + table.push(...cells); + return stripHorizontalLines(table.toString()).trimRight(); +} + +/** + * Strip horizontal lines in the table rendering if the second-column values are the same + * + * We couldn't find a table library that BOTH does newlines-in-cells correctly AND + * has an option to enable/disable separator lines on a per-row basis. So we're + * going to do some character post-processing on the table instead. + */ +function stripHorizontalLines(tableRendering: string) { + const lines = tableRendering.split('\n'); + + let i = 3; + while (i < lines.length - 3) { + if (secondColumnValue(lines[i]) === secondColumnValue(lines[i + 2])) { + lines.splice(i + 1, 1); + i += 1; + } else { + i += 2; + } + } + + return lines.join('\n'); + + function secondColumnValue(line: string) { + const cols = colors.stripColors(line).split('│').filter(x => x !== ''); + return cols[1]; + } } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-changes.ts b/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-changes.ts new file mode 100644 index 0000000000000..6fd16a9759d80 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-changes.ts @@ -0,0 +1,284 @@ +import cfnspec = require('@aws-cdk/cfnspec'); +import colors = require('colors/safe'); +import { PropertyChange, PropertyMap, ResourceChange } from "../diff/types"; +import { DiffableCollection } from '../diffable'; +import { renderIntrinsics } from "../render-intrinsics"; +import { deepRemoveUndefined, dropIfEmpty, flatMap, makeComparator } from '../util'; +import { ManagedPolicyAttachment, ManagedPolicyJson, parseManagedPolicies } from './managed-policy'; +import { parseLambdaPermission, parseStatements, renderCondition, Statement, StatementJson, Targets } from "./statement"; + +export interface IamChangesProps { + propertyChanges: PropertyChange[]; + resourceChanges: ResourceChange[]; +} + +/** + * Changes to IAM statements + */ +export class IamChanges { + public static IamPropertyScrutinies = [ + cfnspec.schema.PropertyScrutinyType.InlineIdentityPolicies, + cfnspec.schema.PropertyScrutinyType.InlineResourcePolicy, + cfnspec.schema.PropertyScrutinyType.ManagedPolicies, + ]; + + public static IamResourceScrutinies = [ + cfnspec.schema.ResourceScrutinyType.ResourcePolicyResource, + cfnspec.schema.ResourceScrutinyType.IdentityPolicyResource, + cfnspec.schema.ResourceScrutinyType.LambdaPermission, + ]; + + public readonly statements = new DiffableCollection(); + public readonly managedPolicies = new DiffableCollection(); + + constructor(props: IamChangesProps) { + for (const propertyChange of props.propertyChanges) { + this.readPropertyChange(propertyChange); + } + for (const resourceChange of props.resourceChanges) { + this.readResourceChange(resourceChange); + } + + this.statements.calculateDiff(); + this.managedPolicies.calculateDiff(); + } + + public get hasChanges() { + return this.statements.hasChanges || this.managedPolicies.hasChanges; + } + + /** + * Return whether the changes include broadened permissions + * + * Permissions are broadened if positive statements are added or + * negative statements are removed, or if managed policies are added. + */ + public get permissionsBroadened(): boolean { + return this.statements.additions.some(s => !s.isNegativeStatement) + || this.statements.removals.some(s => s.isNegativeStatement) + || this.managedPolicies.hasAdditions; + } + + /** + * Return a summary table of changes + */ + public summarizeStatements(): string[][] { + const ret: string[][] = []; + + const header = ['', 'Resource', 'Effect', 'Action', 'Principal', 'Condition']; + + // First generate all lines, then sort on Resource so that similar resources are together + for (const statement of this.statements.additions) { + ret.push([ + '+', + renderTargets(statement.resources), + statement.effect, + renderTargets(statement.actions), + renderTargets(statement.principals), + renderCondition(statement.condition) + ].map(s => colors.green(s))); + } + for (const statement of this.statements.removals) { + ret.push([ + colors.red('-'), + renderTargets(statement.resources), + statement.effect, + renderTargets(statement.actions), + renderTargets(statement.principals), + renderCondition(statement.condition) + ].map(s => colors.red(s))); + } + + // Sort by 2nd column + ret.sort(makeComparator((row: string[]) => [row[1]])); + + ret.splice(0, 0, header); + + return ret; + } + + public summarizeManagedPolicies(): string[][] { + const ret: string[][] = []; + const header = ['', 'Resource', 'Managed Policy ARN']; + + for (const att of this.managedPolicies.additions) { + ret.push([ + '+', + att.identityArn, + att.managedPolicyArn, + ].map(s => colors.green(s))); + } + for (const att of this.managedPolicies.removals) { + ret.push([ + '-', + att.identityArn, + att.managedPolicyArn, + ].map(s => colors.red(s))); + } + + // Sort by 2nd column + ret.sort(makeComparator((row: string[]) => [row[1]])); + + ret.splice(0, 0, header); + + return ret; + } + + /** + * Return a machine-readable version of the changes + */ + public toJson(): IamChangesJson { + return deepRemoveUndefined({ + statementAdditions: dropIfEmpty(this.statements.additions.map(s => s.toJson())), + statementRemovals: dropIfEmpty(this.statements.removals.map(s => s.toJson())), + managedPolicyAdditions: dropIfEmpty(this.managedPolicies.additions.map(s => s.toJson())), + managedPolicyRemovals: dropIfEmpty(this.managedPolicies.removals.map(s => s.toJson())), + }); + } + + private readPropertyChange(propertyChange: PropertyChange) { + switch (propertyChange.scrutinyType) { + case cfnspec.schema.PropertyScrutinyType.InlineIdentityPolicies: + // AWS::IAM::{ Role | User | Group }.Policies + this.statements.addOld(...this.readIdentityPolicies(propertyChange.oldValue, propertyChange.resourceLogicalId)); + this.statements.addNew(...this.readIdentityPolicies(propertyChange.newValue, propertyChange.resourceLogicalId)); + break; + case cfnspec.schema.PropertyScrutinyType.InlineResourcePolicy: + // Any PolicyDocument on a resource (including AssumeRolePolicyDocument) + this.statements.addOld(...this.readResourceStatements(propertyChange.oldValue, propertyChange.resourceLogicalId)); + this.statements.addNew(...this.readResourceStatements(propertyChange.newValue, propertyChange.resourceLogicalId)); + break; + case cfnspec.schema.PropertyScrutinyType.ManagedPolicies: + // Just a list of managed policies + this.managedPolicies.addOld(...this.readManagedPolicies(propertyChange.oldValue, propertyChange.resourceLogicalId)); + this.managedPolicies.addNew(...this.readManagedPolicies(propertyChange.newValue, propertyChange.resourceLogicalId)); + break; + } + } + + private readResourceChange(resourceChange: ResourceChange) { + switch (resourceChange.scrutinyType) { + case cfnspec.schema.ResourceScrutinyType.IdentityPolicyResource: + // AWS::IAM::Policy + this.statements.addOld(...this.readIdentityPolicyResource(resourceChange.oldProperties)); + this.statements.addNew(...this.readIdentityPolicyResource(resourceChange.newProperties)); + break; + case cfnspec.schema.ResourceScrutinyType.ResourcePolicyResource: + // AWS::*::{Bucket,Queue,Topic}Policy + this.statements.addOld(...this.readResourcePolicyResource(resourceChange.oldProperties)); + this.statements.addNew(...this.readResourcePolicyResource(resourceChange.newProperties)); + break; + case cfnspec.schema.ResourceScrutinyType.LambdaPermission: + this.statements.addOld(...this.readLambdaStatements(resourceChange.oldProperties)); + this.statements.addNew(...this.readLambdaStatements(resourceChange.newProperties)); + break; + } + } + + /** + * Parse a list of policies on an identity + */ + private readIdentityPolicies(policies: any, logicalId: string): Statement[] { + if (policies === undefined) { return []; } + + const appliesToPrincipal = 'AWS:${' + logicalId + '}'; + + return flatMap(policies, (policy: any) => { + return defaultPrincipal(appliesToPrincipal, parseStatements(renderIntrinsics(policy.PolicyDocument.Statement))); + }); + } + + /** + * Parse an IAM::Policy resource + */ + private readIdentityPolicyResource(properties: any): Statement[] { + if (properties === undefined) { return []; } + + properties = renderIntrinsics(properties); + + const principals = (properties.Groups || []).concat(properties.Users || []).concat(properties.Roles || []); + return flatMap(principals, (principal: string) => { + const ref = 'AWS:' + principal; + return defaultPrincipal(ref, parseStatements(properties.PolicyDocument.Statement)); + }); + } + + private readResourceStatements(policy: any, logicalId: string): Statement[] { + if (policy === undefined) { return []; } + + const appliesToResource = '${' + logicalId + '.Arn}'; + return defaultResource(appliesToResource, parseStatements(renderIntrinsics(policy.Statement))); + } + + /** + * Parse an AWS::*::{Bucket,Topic,Queue}policy + */ + private readResourcePolicyResource(properties: any): Statement[] { + if (properties === undefined) { return []; } + + properties = renderIntrinsics(properties); + + const policyKeys = Object.keys(properties).filter(key => key.indexOf('Policy') > -1); + + // Find the key that identifies the resource(s) this policy applies to + const resourceKeys = Object.keys(properties).filter(key => !policyKeys.includes(key) && !key.endsWith('Name')); + let resources = resourceKeys.length === 1 ? properties[resourceKeys[0]] : ['???']; + + // For some resources, this is a singleton string, for some it's an array + if (!Array.isArray(resources)) { + resources = [resources]; + } + + return flatMap(resources, (resource: string) => { + return defaultResource(resource, parseStatements(properties[policyKeys[0]].Statement)); + }); + } + + private readManagedPolicies(policyArns: string[] | undefined, logicalId: string): ManagedPolicyAttachment[] { + if (!policyArns) { return []; } + + const rep = '${' + logicalId + '}'; + return parseManagedPolicies(rep, renderIntrinsics(policyArns)); + } + + private readLambdaStatements(properties?: PropertyMap): Statement[] { + if (!properties) { return []; } + + return [parseLambdaPermission(renderIntrinsics(properties))]; + } +} + +/** + * Set an undefined or wildcarded principal on these statements + */ +function defaultPrincipal(principal: string, statements: Statement[]) { + statements.forEach(s => s.principals.replaceEmpty(principal)); + statements.forEach(s => s.principals.replaceStar(principal)); + return statements; +} + +/** + * Set an undefined or wildcarded resource on these statements + */ +function defaultResource(resource: string, statements: Statement[]) { + statements.forEach(s => s.resources.replaceEmpty(resource)); + statements.forEach(s => s.resources.replaceStar(resource)); + return statements; +} + +/** + * Render into a summary table cell + */ +function renderTargets(targets: Targets): string { + if (targets.not) { + return targets.values.map(s => `NOT ${s}`).join('\n'); + } + return targets.values.join('\n'); +} + +export interface IamChangesJson { + statementAdditions?: StatementJson[]; + statementRemovals?: StatementJson[]; + managedPolicyAdditions?: ManagedPolicyJson[]; + managedPolicyRemovals?: ManagedPolicyJson[]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/lib/iam/managed-policy.ts b/packages/@aws-cdk/cloudformation-diff/lib/iam/managed-policy.ts new file mode 100644 index 0000000000000..121b2eae1efd3 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/iam/managed-policy.ts @@ -0,0 +1,22 @@ +export class ManagedPolicyAttachment { + constructor(public readonly identityArn: string, public readonly managedPolicyArn: string) { + } + + public equal(other: ManagedPolicyAttachment) { + return this.identityArn === other.identityArn + && this.managedPolicyArn === other.managedPolicyArn; + } + + public toJson() { + return { identityArn: this.identityArn, managedPolicyArn: this.managedPolicyArn }; + } +} + +export interface ManagedPolicyJson { + identityArn: string; + managedPolicyArn: string; +} + +export function parseManagedPolicies(identityArn: string, arns: string[]): ManagedPolicyAttachment[] { + return arns.map((arn: string) => new ManagedPolicyAttachment(identityArn, arn)); +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/lib/iam/statement.ts b/packages/@aws-cdk/cloudformation-diff/lib/iam/statement.ts new file mode 100644 index 0000000000000..b6dc42fdc988a --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/iam/statement.ts @@ -0,0 +1,262 @@ +import deepEqual = require('fast-deep-equal'); +import { deepRemoveUndefined } from '../util'; + +export class Statement { + /** + * Statement ID + */ + public readonly sid: string | undefined; + + /** + * Statement effect + */ + public readonly effect: Effect; + + /** + * Resources + */ + public readonly resources: Targets; + + /** + * Principals + */ + public readonly principals: Targets; + + /** + * Actions + */ + public readonly actions: Targets; + + /** + * Object with conditions + */ + public readonly condition?: any; + + constructor(statement: UnknownMap) { + this.sid = expectString(statement.Sid); + this.effect = expectEffect(statement.Effect); + this.resources = new Targets(statement, 'Resource', 'NotResource'); + this.actions = new Targets(statement, 'Action', 'NotAction'); + this.principals = new Targets(statement, 'Principal', 'NotPrincipal'); + this.condition = statement.Condition; + } + + /** + * Whether this statement is equal to the other statement + */ + public equal(other: Statement) { + return (this.sid === other.sid + && this.effect === other.effect + && this.resources.equal(other.resources) + && this.actions.equal(other.actions) + && this.principals.equal(other.principals) + && deepEqual(this.condition, other.condition)); + } + + public toJson(): StatementJson { + return deepRemoveUndefined({ + sid: this.sid, + effect: this.effect, + resources: this.resources.toJson(), + principals: this.principals.toJson(), + actions: this.actions.toJson(), + condition: this.condition, + }); + } + + /** + * Whether this is a negative statement + * + * A statement is negative if any of its targets are negative, inverted + * if the Effect is Deny. + */ + public get isNegativeStatement(): boolean { + const notTarget = this.actions.not || this.principals.not || this.resources.not; + return this.effect === Effect.Allow ? notTarget : !notTarget; + } +} + +export interface StatementJson { + sid?: string; + effect: string; + resources: TargetsJson; + actions: TargetsJson; + principals: TargetsJson; + condition?: any; +} + +export interface TargetsJson { + not: boolean; + values: string[]; +} + +/** + * Parse a list of statements from undefined, a Statement, or a list of statements + */ +export function parseStatements(x: any): Statement[] { + if (x === undefined) { x = []; } + if (!Array.isArray(x)) { x = [x]; } + return x.map((s: any) => new Statement(s)); +} + +/** + * Parse a Statement from a Lambda::Permission object + * + * This is actually what Lambda adds to the policy document if you call AddPermission. + */ +export function parseLambdaPermission(x: any): Statement { + // Construct a statement from + const statement: any = { + Effect: 'Allow', + Action: x.Action, + Resource: x.FunctionName, + }; + + if (x.Principal !== undefined) { + if (x.Principal === '*') { + // * + statement.Principal = '*'; + } else if (/^\d{12}$/.test(x.Principal)) { + // Account number + statement.Principal = { AWS: `arn:aws:iam::${x.Principal}:root` }; + } else { + // Assume it's a service principal + // We might get this wrong vs. the previous one for tokens. Nothing to be done + // about that. It's only for human readable consumption after all. + statement.Principal = { Service: x.Principal }; + } + } + if (x.SourceArn !== undefined) { + if (statement.Condition === undefined) { statement.Condition = {}; } + statement.Condition.ArnLike = { 'AWS:SourceArn': x.SourceArn }; + } + if (x.SourceAccount !== undefined) { + if (statement.Condition === undefined) { statement.Condition = {}; } + statement.Condition.StringEquals = { 'AWS:SourceAccount': x.SourceAccount }; + } + if (x.EventSourceToken !== undefined) { + if (statement.Condition === undefined) { statement.Condition = {}; } + statement.Condition.StringEquals = { 'lambda:EventSourceToken': x.EventSourceToken }; + } + + return new Statement(statement); +} + +/** + * Targets for a field + */ +export class Targets { + /** + * The values of the targets + */ + public readonly values: string[]; + + /** + * Whether positive or negative matchers + */ + public readonly not: boolean; + + constructor(statement: UnknownMap, positiveKey: string, negativeKey: string) { + if (negativeKey in statement) { + this.values = forceListOfStrings(statement[negativeKey]); + this.not = true; + } else { + this.values = forceListOfStrings(statement[positiveKey]); + this.not = false; + } + this.values.sort(); + } + + public get empty() { + return this.values.length === 0; + } + + /** + * Whether this set of targets is equal to the other set of targets + */ + public equal(other: Targets) { + return this.not === other.not && deepEqual(this.values.sort(), other.values.sort()); + } + + /** + * If the current value set is empty, put this in it + */ + public replaceEmpty(replacement: string) { + if (this.empty) { + this.values.push(replacement); + } + } + + /** + * If the actions contains a '*', replace with this string. + */ + public replaceStar(replacement: string) { + for (let i = 0; i < this.values.length; i++) { + if (this.values[i] === '*') { + this.values[i] = replacement; + } + } + this.values.sort(); + } + + public toJson(): TargetsJson { + return { not: this.not, values: this.values }; + } +} + +type UnknownMap = {[key: string]: unknown}; + +export enum Effect { + Unknown = 'Unknown', + Allow = 'Allow', + Deny = 'Deny', +} + +function expectString(x: unknown): string | undefined { + return typeof x === 'string' ? x : undefined; +} + +function expectEffect(x: unknown): Effect { + if (x === Effect.Allow || x === Effect.Deny) { return x as Effect; } + return Effect.Unknown; +} + +function forceListOfStrings(x: unknown): string[] { + if (typeof x === 'string') { return [x]; } + if (typeof x === 'undefined' || x === null) { return []; } + + if (Array.isArray(x)) { + return x.map(e => forceListOfStrings(e).join(',')); + } + + if (typeof x === 'object' && x !== null) { + const ret: string[] = []; + for (const [key, value] of Object.entries(x)) { + ret.push(...forceListOfStrings(value).map(s => `${key}:${s}`)); + } + return ret; + } + + return [`${x}`]; +} + +/** + * Render the Condition column + */ +export function renderCondition(condition: any) { + if (!condition || Object.keys(condition).length === 0) { return ''; } + const jsonRepresentation = JSON.stringify(condition, undefined, 2); + + // The JSON representation looks like this: + // + // { + // "ArnLike": { + // "AWS:SourceArn": "${MyTopic86869434}" + // } + // } + // + // We can make it more compact without losing information by getting rid of the outermost braces + // and the indentation. + const lines = jsonRepresentation.split('\n'); + return lines.slice(1, lines.length - 1).map(s => s.substr(2)).join('\n'); +} diff --git a/packages/@aws-cdk/cloudformation-diff/lib/network/security-group-changes.ts b/packages/@aws-cdk/cloudformation-diff/lib/network/security-group-changes.ts new file mode 100644 index 0000000000000..dca91de398547 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/network/security-group-changes.ts @@ -0,0 +1,122 @@ +import colors = require('colors/safe'); +import { PropertyChange, ResourceChange } from "../diff/types"; +import { DiffableCollection } from "../diffable"; +import { renderIntrinsics } from "../render-intrinsics"; +import { deepRemoveUndefined, dropIfEmpty, makeComparator } from '../util'; +import { RuleJson, SecurityGroupRule } from "./security-group-rule"; + +export interface SecurityGroupChangesProps { + ingressRulePropertyChanges: PropertyChange[]; + ingressRuleResourceChanges: ResourceChange[]; + egressRuleResourceChanges: ResourceChange[]; + egressRulePropertyChanges: PropertyChange[]; +} + +/** + * Changes to IAM statements + */ +export class SecurityGroupChanges { + public readonly ingress = new DiffableCollection(); + public readonly egress = new DiffableCollection(); + + constructor(props: SecurityGroupChangesProps) { + // Group rules + for (const ingressProp of props.ingressRulePropertyChanges) { + this.ingress.addOld(...this.readInlineRules(ingressProp.oldValue, ingressProp.resourceLogicalId)); + this.ingress.addNew(...this.readInlineRules(ingressProp.newValue, ingressProp.resourceLogicalId)); + } + for (const egressProp of props.egressRulePropertyChanges) { + this.egress.addOld(...this.readInlineRules(egressProp.oldValue, egressProp.resourceLogicalId)); + this.egress.addNew(...this.readInlineRules(egressProp.newValue, egressProp.resourceLogicalId)); + } + + // Rule resources + for (const ingressRes of props.ingressRuleResourceChanges) { + this.ingress.addOld(...this.readRuleResource(ingressRes.oldProperties)); + this.ingress.addNew(...this.readRuleResource(ingressRes.newProperties)); + } + for (const egressRes of props.egressRuleResourceChanges) { + this.egress.addOld(...this.readRuleResource(egressRes.oldProperties)); + this.egress.addNew(...this.readRuleResource(egressRes.newProperties)); + } + + this.ingress.calculateDiff(); + this.egress.calculateDiff(); + } + + public get hasChanges() { + return this.ingress.hasChanges || this.egress.hasChanges; + } + + /** + * Return a summary table of changes + */ + public summarize(): string[][] { + const ret: string[][] = []; + + const header = ['', 'Group', 'Dir', 'Protocol', 'Peer']; + + const inWord = 'In'; + const outWord = 'Out'; + + // Render a single rule to the table (curried function so we can map it across rules easily--thank you JavaScript!) + const renderRule = (plusMin: string, inOut: string) => (rule: SecurityGroupRule) => [ + plusMin, + rule.groupId, + inOut, + rule.describeProtocol(), + rule.describePeer(), + ].map(s => plusMin === '+' ? colors.green(s) : colors.red(s)); + + // First generate all lines, sort later + ret.push(...this.ingress.additions.map(renderRule('+', inWord))); + ret.push(...this.egress.additions.map(renderRule('+', outWord))); + ret.push(...this.ingress.removals.map(renderRule('-', inWord))); + ret.push(...this.egress.removals.map(renderRule('-', outWord))); + + // Sort by group name then ingress/egress (ingress first) + ret.sort(makeComparator((row: string[]) => [row[1], row[2].indexOf(inWord) > -1 ? 0 : 1])); + + ret.splice(0, 0, header); + + return ret; + } + + public toJson(): SecurityGroupChangesJson { + return deepRemoveUndefined({ + ingressRuleAdditions: dropIfEmpty(this.ingress.additions.map(s => s.toJson())), + ingressRuleRemovals: dropIfEmpty(this.ingress.removals.map(s => s.toJson())), + egressRuleAdditions: dropIfEmpty(this.egress.additions.map(s => s.toJson())), + egressRuleRemovals: dropIfEmpty(this.egress.removals.map(s => s.toJson())), + }); + } + + public get rulesAdded(): boolean { + return this.ingress.hasAdditions + || this.egress.hasAdditions; + } + + private readInlineRules(rules: any, logicalId: string): SecurityGroupRule[] { + if (!rules) { return []; } + + // UnCloudFormation so the parser works in an easier domain + + const ref = '${' + logicalId + '.GroupId}'; + return rules.map((r: any) => new SecurityGroupRule(renderIntrinsics(r), ref)); + } + + private readRuleResource(resource: any): SecurityGroupRule[] { + if (!resource) { return []; } + + // UnCloudFormation so the parser works in an easier domain + + return [new SecurityGroupRule(renderIntrinsics(resource))]; + } +} + +export interface SecurityGroupChangesJson { + ingressRuleAdditions?: RuleJson[]; + ingressRuleRemovals?: RuleJson[]; + egressRuleAdditions?: RuleJson[]; + egressRuleRemovals?: RuleJson[]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/lib/network/security-group-rule.ts b/packages/@aws-cdk/cloudformation-diff/lib/network/security-group-rule.ts new file mode 100644 index 0000000000000..38385b2fa0ba2 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/network/security-group-rule.ts @@ -0,0 +1,137 @@ +/** + * A single security group rule, either egress or ingress + */ +export class SecurityGroupRule { + /** + * Group ID of the group this rule applies to + */ + public readonly groupId: string; + + /** + * IP protocol this rule applies to + */ + public readonly ipProtocol: string; + + /** + * Start of port range this rule applies to, or ICMP type + */ + public readonly fromPort?: number; + + /** + * End of port range this rule applies to, or ICMP code + */ + public readonly toPort?: number; + + /** + * Peer of this rule + */ + public readonly peer?: RulePeer; + + constructor(ruleObject: any, groupRef?: string) { + this.ipProtocol = ruleObject.IpProtocol || '*unknown*'; + this.fromPort = ruleObject.FromPort; + this.toPort = ruleObject.ToPort; + this.groupId = ruleObject.GroupId || groupRef || '*unknown*'; // In case of an inline rule + + this.peer = + findFirst(ruleObject, + ['CidrIp', 'CidrIpv6'], + (ip) => ({ kind: 'cidr-ip', ip } as CidrIpPeer)) + || + findFirst(ruleObject, + ['DestinationSecurityGroupId', 'SourceSecurityGroupId'], + (securityGroupId) => ({ kind: 'security-group', securityGroupId } as SecurityGroupPeer)) + || + findFirst(ruleObject, + ['DestinationPrefixListId', 'SourcePrefixListId'], + (prefixListId) => ({ kind: 'prefix-list', prefixListId } as PrefixListPeer)); + } + + public equal(other: SecurityGroupRule) { + return this.ipProtocol === other.ipProtocol + && this.fromPort === other.fromPort + && this.toPort === other.toPort + && peerEqual(this.peer, other.peer); + } + + public describeProtocol() { + if (this.ipProtocol === '-1') { return 'Everything'; } + + const ipProtocol = this.ipProtocol.toUpperCase(); + + if (this.fromPort === -1) { return `All ${ipProtocol}`; } + if (this.fromPort === this.toPort) { return `${ipProtocol} ${this.fromPort}`; } + return `${ipProtocol} ${this.fromPort}-${this.toPort}`; + } + + public describePeer() { + if (this.peer) { + switch (this.peer.kind) { + case 'cidr-ip': + if (this.peer.ip === '0.0.0.0/0') { return 'Everyone (IPv4)'; } + if (this.peer.ip === '::/0') { return 'Everyone (IPv6)'; } + return `${this.peer.ip}`; + case 'prefix-list': return `${this.peer.prefixListId}`; + case 'security-group': return `${this.peer.securityGroupId}`; + } + } + + return '?'; + } + + public toJson(): RuleJson { + return { + groupId: this.groupId, + ipProtocol: this.ipProtocol, + fromPort: this.fromPort, + toPort: this.toPort, + peer: this.peer + }; + } +} + +export interface CidrIpPeer { + kind: 'cidr-ip'; + ip: string; +} + +export interface SecurityGroupPeer { + kind: 'security-group'; + securityGroupId: string; +} + +export interface PrefixListPeer { + kind: 'prefix-list'; + prefixListId: string; +} + +export type RulePeer = CidrIpPeer | SecurityGroupPeer | PrefixListPeer; + +function peerEqual(a?: RulePeer, b?: RulePeer) { + if ((a === undefined) !== (b === undefined)) { return false; } + if (a === undefined) { return true; } + + if (a.kind !== b!.kind) { return false; } + switch (a.kind) { + case 'cidr-ip': return a.ip === (b as typeof a).ip; + case 'security-group': return a.securityGroupId === (b as typeof a).securityGroupId; + case 'prefix-list': return a.prefixListId === (b as typeof a).prefixListId; + } +} + +function findFirst(obj: any, keys: string[], fn: (x: string) => T): T | undefined { + for (const key of keys) { + if (key in obj) { + return fn(obj[key]); + } + } + return undefined; +} + +export interface RuleJson { + groupId: string; + ipProtocol: string; + fromPort?: number; + toPort?: number; + peer?: RulePeer; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/lib/render-intrinsics.ts b/packages/@aws-cdk/cloudformation-diff/lib/render-intrinsics.ts new file mode 100644 index 0000000000000..ea392d2de8444 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/render-intrinsics.ts @@ -0,0 +1,63 @@ +/** + * Turn CloudFormation intrinsics into strings + * + * ------ + * + * This stringification is not intended to be mechanically reversible! It's intended + * to be understood by humans! + * + * ------ + * + * Turns Fn::GetAtt and Fn::Ref objects into the same strings that can be + * parsed by Fn::Sub, but without the surrounding intrinsics. + * + * Evaluates Fn::Join directly if the second argument is a literal list of strings. + * + * For other intrinsics we choose a string representation that CloudFormation + * cannot actually parse, but is comprehensible to humans. + */ +export function renderIntrinsics(x: any): any { + if (Array.isArray(x)) { + return x.map(renderIntrinsics); + } + + const intrinsic = getIntrinsic(x); + if (intrinsic) { + if (intrinsic.fn === 'Ref') { return '${' + intrinsic.args + '}'; } + if (intrinsic.fn === 'Fn::GetAtt') { return '${' + intrinsic.args[0] + '.' + intrinsic.args[1] + '}'; } + if (intrinsic.fn === 'Fn::Join') { return unCloudFormationFnJoin(intrinsic.args[0], intrinsic.args[1]); } + return stringifyIntrinsic(intrinsic.fn, intrinsic.args); + } + + if (typeof x === 'object' && x !== null) { + const ret: any = {}; + for (const [key, value] of Object.entries(x)) { + ret[key] = renderIntrinsics(value); + } + return ret; + } + return x; +} + +function unCloudFormationFnJoin(separator: string, args: any) { + if (Array.isArray(args)) { + return args.map(renderIntrinsics).join(separator); + } + return stringifyIntrinsic('Fn::Join', [separator, args]); +} + +function stringifyIntrinsic(fn: string, args: any) { + return JSON.stringify({ [fn]: renderIntrinsics(args) }); +} + +function getIntrinsic(x: any): Intrinsic | undefined { + if (x === undefined || x === null || Array.isArray(x)) { return undefined; } + if (typeof x !== 'object') { return undefined; } + const keys = Object.keys(x); + return keys.length === 1 && (keys[0] === 'Ref' || keys[0].startsWith('Fn::')) ? { fn: keys[0], args: x[keys[0]] } : undefined; +} + +interface Intrinsic { + fn: string; + args: any; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/lib/util.ts b/packages/@aws-cdk/cloudformation-diff/lib/util.ts new file mode 100644 index 0000000000000..5fcb761b2a284 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/util.ts @@ -0,0 +1,49 @@ +/** + * Turn a (multi-key) extraction function into a comparator for use in Array.sort() + */ +export function makeComparator(keyFn: (x: T) => U[]) { + return (a: T, b: T) => { + const keyA = keyFn(a); + const keyB = keyFn(b); + const len = Math.min(keyA.length, keyB.length); + + for (let i = 0; i < len; i++) { + const c = compare(keyA[i], keyB[i]); + if (c !== 0) { return c; } + } + + // Arrays are the same up to the min length -- shorter array sorts first + return keyA.length - keyB.length; + }; +} + +function compare(a: T, b: T) { + if (a < b) { return -1; } + if (b < a) { return 1; } + return 0; +} + +export function dropIfEmpty(xs: T[]): T[] | undefined { + return xs.length > 0 ? xs : undefined; +} + +export function deepRemoveUndefined(x: any): any { + if (typeof x === undefined || x === null) { return x; } + if (Array.isArray(x)) { return x.map(deepRemoveUndefined); } + if (typeof x === 'object') { + for (const [key, value] of Object.entries(x)) { + x[key] = deepRemoveUndefined(value); + if (x[key] === undefined) { delete x[key]; } + } + return x; + } + return x; +} + +export function flatMap(xs: T[], f: (x: T) => U[]): U[] { + const ret = new Array(); + for (const x of xs) { + ret.push(...f(x)); + } + return ret; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/package-lock.json b/packages/@aws-cdk/cloudformation-diff/package-lock.json index 445bc02dcc872..7ac316ae5efc2 100644 --- a/packages/@aws-cdk/cloudformation-diff/package-lock.json +++ b/packages/@aws-cdk/cloudformation-diff/package-lock.json @@ -1,19 +1,76 @@ { "name": "@aws-cdk/cloudformation-diff", - "version": "0.12.0", + "version": "0.18.1", "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/cli-table": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@types/cli-table/-/cli-table-0.3.0.tgz", + "integrity": "sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ==", + "dev": true + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" }, + "cli-table": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.1.tgz", + "integrity": "sha1-9TsFJmqLGguTSz0IIebi3FkUriM=", + "requires": { + "colors": "1.0.3" + }, + "dependencies": { + "colors": { + "version": "1.0.3", + "resolved": "http://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + } + } + }, "colors": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.2.tgz", "integrity": "sha512-rhP0JSBGYvpcNQj4s5AdShMeE5ahMop96cTeDl/v9qQQm2fYClE2QXZRi8wLzc+GmXSxdIqqbOIAhyObEXDbfQ==" }, + "fast-check": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-1.8.0.tgz", + "integrity": "sha512-BkXKjgri0+wnqrDNv4kma/2YwrAq9SyPBd/7wzMaYGMAT+s100YHDz0K1y+IQf4yOK40CFLwK5BSh0cyY9F1/A==", + "dev": true, + "requires": { + "lorem-ipsum": "~1.0.6", + "pure-rand": "^1.5.0" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + }, + "lorem-ipsum": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/lorem-ipsum/-/lorem-ipsum-1.0.6.tgz", + "integrity": "sha512-Rx4XH8X4KSDCKAVvWGYlhAfNqdUP5ZdT4rRyf0jjrvWgtViZimDIlopWNfn/y3lGM5K4uuiAoY28TaD+7YKFrQ==", + "dev": true, + "requires": { + "minimist": "~1.2.0" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "pure-rand": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-1.5.0.tgz", + "integrity": "sha512-acykcCwcBmt5l4w/lPxPUxMeYLb6qwsve3v9A44SNckwOyuXxZgTfP5qAsH/CyZSGkHdUnOQGmckOExOldivOw==", + "dev": true + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/packages/@aws-cdk/cloudformation-diff/package.json b/packages/@aws-cdk/cloudformation-diff/package.json index 6257acc57328b..a2c4a63117236 100644 --- a/packages/@aws-cdk/cloudformation-diff/package.json +++ b/packages/@aws-cdk/cloudformation-diff/package.json @@ -25,11 +25,15 @@ "dependencies": { "@aws-cdk/cfnspec": "^0.19.0", "@aws-cdk/cx-api": "^0.19.0", + "cli-table": "^0.3.1", "colors": "^1.2.1", + "fast-deep-equal": "^2.0.1", "source-map-support": "^0.5.6" }, "devDependencies": { + "@types/cli-table": "^0.3.0", "cdk-build-tools": "^0.19.0", + "fast-check": "^1.8.0", "pkglint": "^0.19.0" }, "repository": { diff --git a/packages/@aws-cdk/cloudformation-diff/test/iam/test.broadening.ts b/packages/@aws-cdk/cloudformation-diff/test/iam/test.broadening.ts new file mode 100644 index 0000000000000..27b45f54e7651 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/iam/test.broadening.ts @@ -0,0 +1,211 @@ +import { Test } from 'nodeunit'; +import { diffTemplate } from '../../lib'; +import { poldoc, resource, template } from '../util'; + +export = { + 'broadening is': { + 'adding of positive statements'(test: Test) { + // WHEN + const diff = diffTemplate({}, template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue' } ], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + }) + }) + })); + + // THEN + test.equal(diff.permissionsBroadened, true); + + test.done(); + }, + + 'adding of positive statements to an existing policy'(test: Test) { + // WHEN + const diff = diffTemplate(template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue' } ], + PolicyDocument: poldoc( + { + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + } + ) + }) + }), template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue' } ], + PolicyDocument: poldoc( + { + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + }, + { + Effect: 'Allow', + Action: 'sqs:LookAtMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + } + ) + }) + })); + + // THEN + test.equal(diff.permissionsBroadened, true); + + test.done(); + }, + + 'removal of not-statements'(test: Test) { + // WHEN + const diff = diffTemplate(template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue' } ], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + NotPrincipal: { Service: 'sns.amazonaws.com' } + }) + }) + }), {}); + + // THEN + test.equal(diff.permissionsBroadened, true); + + test.done(); + }, + + 'changing of resource target'(test: Test) { + // WHEN + const diff = diffTemplate(template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue' } ], + PolicyDocument: poldoc( + { + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + } + ) + }) + }), template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyOtherQueue' } ], + PolicyDocument: poldoc( + { + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + } + ) + }) + })); + + // THEN + test.equal(diff.permissionsBroadened, true); + + test.done(); + }, + + 'addition of ingress rules'(test: Test) { + // WHEN + const diff = diffTemplate( + template({ + }), + template({ + SG: resource('AWS::EC2::SecurityGroup', { + SecurityGroupIngress: [ + { + CidrIp: '1.2.3.4/8', + FromPort: 80, + ToPort: 80, + IpProtocol: 'tcp', + } + ], + }) + })); + + // THEN + test.equal(diff.permissionsBroadened, true); + + test.done(); + }, + + 'addition of egress rules'(test: Test) { + // WHEN + const diff = diffTemplate( + template({ + }), + template({ + SG: resource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + DestinationSecurityGroupId: { 'Fn::GetAtt': ['ThatOtherGroup', 'GroupId'] }, + FromPort: 80, + ToPort: 80, + IpProtocol: 'tcp', + } + ], + }) + })); + + // THEN + test.equal(diff.permissionsBroadened, true); + + test.done(); + }, + }, + 'broadening is not': { + 'removal of positive statements from an existing policy'(test: Test) { + // WHEN + const diff = diffTemplate(template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue' } ], + PolicyDocument: poldoc( + { + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + }, + { + Effect: 'Allow', + Action: 'sqs:LookAtMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + } + ) + }) + }), template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue' } ], + PolicyDocument: poldoc( + { + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + } + ) + }) + })); + + // THEN + test.equal(diff.permissionsBroadened, false); + + test.done(); + }, + } + +}; \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/test/iam/test.detect-changes.ts b/packages/@aws-cdk/cloudformation-diff/test/iam/test.detect-changes.ts new file mode 100644 index 0000000000000..5006b470cdb5c --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/iam/test.detect-changes.ts @@ -0,0 +1,359 @@ +import { Test } from 'nodeunit'; +import { diffTemplate } from '../../lib'; +import { poldoc, policy, resource, role, template } from '../util'; + +export = { + 'shows new AssumeRolePolicyDocument'(test: Test) { + // WHEN + const diff = diffTemplate({}, template({ + MyRole: role({ + AssumeRolePolicyDocument: poldoc({ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { Service: 'lambda.amazonaws.com' } + }) + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementAdditions: [ + { + effect: 'Allow', + resources: { not: false, values: [ '${MyRole.Arn}' ] }, + principals: { not: false, values: [ 'Service:lambda.amazonaws.com' ] }, + actions: { not: false, values: [ 'sts:AssumeRole' ] }, + } + ] + }); + + test.done(); + }, + + 'implicitly knows principal of identity policy for all resource types'(test: Test) { + for (const attr of ['Roles', 'Users', 'Groups']) { + // WHEN + const diff = diffTemplate({}, template({ + MyPolicy: policy({ + [attr]: [{ Ref: 'MyRole' }], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 's3:DoThatThing', + Resource: '*' + }) + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementAdditions: [ + { + effect: 'Allow', + resources: { not: false, values: [ '*' ] }, + principals: { not: false, values: [ 'AWS:${MyRole}' ] }, + actions: { not: false, values: [ 's3:DoThatThing' ] }, + } + ] + }); + } + + test.done(); + }, + + 'policies on an identity object'(test: Test) { + for (const resourceType of ['Role', 'User', 'Group']) { + // WHEN + const diff = diffTemplate({}, template({ + MyIdentity: resource(`AWS::IAM::${resourceType}`, { + Policies: [ + { + PolicyName: 'Polly', + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 's3:DoThatThing', + Resource: '*' + }) + } + ], + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementAdditions: [ + { + effect: 'Allow', + resources: { not: false, values: [ '*' ] }, + principals: { not: false, values: [ 'AWS:${MyIdentity}' ] }, + actions: { not: false, values: [ 's3:DoThatThing' ] }, + } + ] + }); + } + + test.done(); + }, + + 'if policy is attached to multiple roles all are shown'(test: Test) { + // WHEN + const diff = diffTemplate({}, template({ + MyPolicy: policy({ + Roles: [{ Ref: 'MyRole' }, { Ref: 'ThyRole' }], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 's3:DoThatThing', + Resource: '*' + }) + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementAdditions: [ + { + effect: 'Allow', + resources: { not: false, values: [ '*' ] }, + principals: { not: false, values: [ 'AWS:${MyRole}' ] }, + actions: { not: false, values: [ 's3:DoThatThing' ] }, + }, + { + effect: 'Allow', + resources: { not: false, values: [ '*' ] }, + principals: { not: false, values: [ 'AWS:${ThyRole}' ] }, + actions: { not: false, values: [ 's3:DoThatThing' ] }, + }, + ] + }); + + test.done(); + }, + + 'correctly parses Lambda permissions'(test: Test) { + // WHEN + const diff = diffTemplate({}, template({ + MyPermission: resource('AWS::Lambda::Permission', { + Action: 'lambda:InvokeFunction', + FunctionName: { Ref: 'MyFunction' }, + Principal: 's3.amazonaws.com', + SourceAccount: {Ref: 'AWS::AccountId' }, + SourceArn: {'Fn::GetAtt': ['MyBucketF68F3FF0', 'Arn']}, + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementAdditions: [ + { + effect: 'Allow', + resources: { not: false, values: [ '${MyFunction}' ] }, + principals: { not: false, values: [ 'Service:s3.amazonaws.com' ] }, + actions: { not: false, values: [ 'lambda:InvokeFunction' ] }, + condition: { + StringEquals: { 'AWS:SourceAccount': '${AWS::AccountId}' }, + ArnLike: { 'AWS:SourceArn': '${MyBucketF68F3FF0.Arn}' } + }, + } + ] + }); + test.done(); + }, + + 'implicitly knows resource of (queue) resource policy even if * given'(test: Test) { + // WHEN + const diff = diffTemplate({}, template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue' } ], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + }) + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementAdditions: [ + { + effect: 'Allow', + resources: { not: false, values: [ '${MyQueue}' ] }, + principals: { not: false, values: [ 'Service:sns.amazonaws.com' ] }, + actions: { not: false, values: [ 'sqs:SendMessage' ] }, + } + ] + }); + + test.done(); + }, + + 'finds sole statement removals'(test: Test) { + // WHEN + const diff = diffTemplate(template({ + BucketPolicy: resource('AWS::S3::BucketPolicy', { + Bucket: { Ref: 'MyBucket' }, + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 's3:PutObject', + Resource: '*', + Principal: { AWS: 'me' } + }) + }) + }), {}); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementRemovals: [ + { + effect: 'Allow', + resources: { not: false, values: [ '${MyBucket}' ] }, + principals: { not: false, values: [ 'AWS:me' ] }, + actions: { not: false, values: [ 's3:PutObject' ] }, + } + ] + }); + + test.done(); + }, + + 'finds one of many statement removals'(test: Test) { + // WHEN + const diff = diffTemplate( + template({ + BucketPolicy: resource('AWS::S3::BucketPolicy', { + Bucket: { Ref: 'MyBucket' }, + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 's3:PutObject', + Resource: '*', + Principal: { AWS: 'me' } + }, { + Effect: 'Allow', + Action: 's3:LookAtObject', + Resource: '*', + Principal: { AWS: 'me' } + }) + }) + }), + template({ + BucketPolicy: resource('AWS::S3::BucketPolicy', { + Bucket: { Ref: 'MyBucket' }, + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 's3:LookAtObject', + Resource: '*', + Principal: { AWS: 'me' } + }) + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementRemovals: [ + { + effect: 'Allow', + resources: { not: false, values: [ '${MyBucket}' ] }, + principals: { not: false, values: [ 'AWS:me' ] }, + actions: { not: false, values: [ 's3:PutObject' ] }, + } + ] + }); + + test.done(); + }, + + 'finds policy attachments'(test: Test) { + // WHEN + const diff = diffTemplate({}, template({ + SomeRole: resource('AWS::IAM::Role', { + ManagedPolicyArns: ['arn:policy'], + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + managedPolicyAdditions: [ + { + identityArn: '${SomeRole}', + managedPolicyArn: 'arn:policy' + } + ] + }); + + test.done(); + }, + + 'finds policy removals'(test: Test) { + // WHEN + const diff = diffTemplate( + template({ + SomeRole: resource('AWS::IAM::Role', { + ManagedPolicyArns: ['arn:policy', 'arn:policy2'], + }) + }), + template({ + SomeRole: resource('AWS::IAM::Role', { + ManagedPolicyArns: ['arn:policy2'], + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + managedPolicyRemovals: [ + { + identityArn: '${SomeRole}', + managedPolicyArn: 'arn:policy' + } + ] + }); + + test.done(); + }, + + 'queuepolicy queue change counts as removal+addition'(test: Test) { + // WHEN + const diff = diffTemplate(template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue1' } ], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + }) + }) + }), template({ + QueuePolicy: resource('AWS::SQS::QueuePolicy', { + Queues: [ { Ref: 'MyQueue2' } ], + PolicyDocument: poldoc({ + Effect: 'Allow', + Action: 'sqs:SendMessage', + Resource: '*', + Principal: { Service: 'sns.amazonaws.com' } + }) + }) + })); + + // THEN + test.deepEqual(diff.iamChanges.toJson(), { + statementAdditions: [ + { + effect: 'Allow', + resources: { not: false, values: [ '${MyQueue2}' ] }, + principals: { not: false, values: [ 'Service:sns.amazonaws.com' ] }, + actions: { not: false, values: [ 'sqs:SendMessage' ] }, + } + ], + statementRemovals: [ + { + effect: 'Allow', + resources: { not: false, values: [ '${MyQueue1}' ] }, + principals: { not: false, values: [ 'Service:sns.amazonaws.com' ] }, + actions: { not: false, values: [ 'sqs:SendMessage' ] }, + } + ] + }); + + test.done(); + }, +}; \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/test/iam/test.statement.ts b/packages/@aws-cdk/cloudformation-diff/test/iam/test.statement.ts new file mode 100644 index 0000000000000..1f6bdf4d246ab --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/iam/test.statement.ts @@ -0,0 +1,235 @@ +import fc = require('fast-check'); +import { Test } from 'nodeunit'; +import { parseLambdaPermission, renderCondition, Statement } from '../../lib/iam/statement'; + +export = { + 'can parse all positive fields'(test: Test) { + const statement = new Statement({ + Sid: 'Sid', + Effect: 'Allow', + Resource: ['resource'], + Action: ['action'], + Principal: [{AWS: 'arn'}], + Condition: { StringEquals: { 'Amzn-This': 'That' } } + }); + + test.equal(statement.sid, 'Sid'); + test.equal(statement.effect, 'Allow'); + test.deepEqual(statement.resources.values, ['resource']); + test.deepEqual(statement.actions.values, ['action']); + test.deepEqual(statement.principals.values, ['AWS:arn']); + test.deepEqual(statement.condition, { StringEquals: { 'Amzn-This': 'That' } }); + + test.equal(statement.resources.not, false); + test.equal(statement.actions.not, false); + test.equal(statement.principals.not, false); + + test.done(); + }, + + 'parses strings as singleton lists'(test: Test) { + const statement = new Statement({ + Resource: 'resource' + }); + + test.deepEqual(statement.resources.values, ['resource']); + + test.done(); + }, + + 'correctly parses NotFields'(test: Test) { + const statement = new Statement({ + NotResource: ['resource'], + NotAction: ['action'], + NotPrincipal: [{AWS: 'arn'}], + }); + + test.equal(statement.resources.not, true); + test.equal(statement.actions.not, true); + test.equal(statement.principals.not, true); + + test.done(); + }, + + 'parse all LambdaPermission fields'(test: Test) { + const statement = parseLambdaPermission({ + Action: 'lambda:CallMeMaybe', + FunctionName: 'Function', + Principal: '*', + SourceAccount: '123456789012', + SourceArn: 'arn', + }); + + test.deepEqual(statement.actions.values, ['lambda:CallMeMaybe']); + test.deepEqual(statement.resources.values, ['Function']); + test.deepEqual(statement.principals.values, ['*']); + test.deepEqual(statement.condition, { + ArnLike: { 'AWS:SourceArn': 'arn' }, + StringEquals: { 'AWS:SourceAccount': '123456789012', }, + }); + + test.done(); + }, + + 'parse lambda eventsourcetoken'(test: Test) { + const statement = parseLambdaPermission({ + Action: 'lambda:CallMeMaybe', + FunctionName: 'Function', + EventSourceToken: 'token', + Principal: '*', + }); + + test.deepEqual(statement.condition, { + StringEquals: { 'lambda:EventSourceToken': 'token' }, + }); + + test.done(); + }, + + 'stringify complex condition'(test: Test) { + // WHEN + const stringified = renderCondition({ + StringEquals: { 'AWS:SourceAccount': '${AWS::AccountId}' }, + ArnLike: { 'AWS:SourceArn': '${MyBucket.Arn}' } + }).split('\n'); + + // THEN + test.deepEqual(stringified, [ + '"StringEquals": {', + ' "AWS:SourceAccount": "${AWS::AccountId}"', + '},', + '"ArnLike": {', + ' "AWS:SourceArn": "${MyBucket.Arn}"', + '}', + ]); + + test.done(); + }, + + 'an Allow statement with a NotPrincipal is negative'(test: Test) { + // WHEN + const statement = new Statement({ + Effect: 'Allow', + Resource: 'resource', + NotPrincipal: { AWS: 'me' }, + }); + + // THEN + test.equals(statement.isNegativeStatement, true); + + test.done(); + }, + + 'a Deny statement with a NotPrincipal is positive'(test: Test) { + // In effect, this is a roundabout way of saying only the given Principal + // should be allowed ("everyone who's not me can't do this"). + + // WHEN + const statement = new Statement({ + Effect: 'Deny', + Resource: 'resource', + NotPrincipal: { AWS: 'me' }, + }); + + // THEN + test.equals(statement.isNegativeStatement, false); + + test.done(); + }, + + 'equality is reflexive'(test: Test) { + fc.assert(fc.property( + arbitraryStatement, (statement) => { + return new Statement(statement).equal(new Statement(statement)); + } + )); + test.done(); + }, + + 'equality is symmetric'(test: Test) { + fc.assert(fc.property( + twoArbitraryStatements, (s) => { + const a = new Statement(s.statement1); + const b = new Statement(s.statement2); + + fc.pre(a.equal(b)); + return b.equal(a); + } + )); + test.done(); + }, + + // We should be testing transitivity as well but it's too much code to generate + // arbitraries that satisfy the precondition enough times to be useful. +}; + +const arbitraryResource = fc.oneof(fc.constantFrom('*', 'arn:resource')); +const arbitraryAction = fc.constantFrom('*', 's3:*', 's3:GetObject', 's3:PutObject'); +const arbitraryPrincipal = fc.oneof( + fc.constant(undefined), + fc.constant('*'), + fc.record({ AWS: fc.oneof(fc.string(), fc.constant('*')) }), + fc.record({ Service: fc.string() }), + fc.record({ Federated: fc.string() }) +); +const arbitraryCondition = fc.oneof( + fc.constant(undefined), + fc.constant({ StringEquals: { Key: 'Value' }}), + fc.constant({ StringEquals: { Key: 'Value' }, NumberEquals: { Key: 5 }}), +); + +const arbitraryStatement = fc.record({ + Sid: fc.oneof(fc.string(), fc.constant(undefined)), + Effect: fc.constantFrom('Allow', 'Deny'), + Resource: fc.array(arbitraryResource, 0, 2), + NotResource: fc.boolean(), + Action: fc.array(arbitraryAction, 1, 2), + NotAction: fc.boolean(), + Principal: fc.array(arbitraryPrincipal, 0, 2), + NotPrincipal: fc.boolean(), + Condition: arbitraryCondition +}).map(record => { + // This map() that shuffles keys is the easiest way to create variation between Action/NotAction etc. + makeNot(record, 'Resource', 'NotResource'); + makeNot(record, 'Action', 'NotAction'); + makeNot(record, 'Principal', 'NotPrincipal'); + return record; +}); + +function makeNot(obj: any, key: string, notKey: string) { + if (obj[notKey]) { + obj[notKey] = obj[key]; + delete obj[key]; + } else { + delete obj[notKey]; + } +} + +/** + * Two statements where one is a modification of the other + * + * This is to generate two statements that have a higher chance of being similar + * than generating two arbitrary statements independently. + */ +const twoArbitraryStatements = fc.record({ + statement1: arbitraryStatement, + statement2: arbitraryStatement, + copySid: fc.boolean(), + copyEffect: fc.boolean(), + copyResource: fc.boolean(), + copyAction: fc.boolean(), + copyPrincipal: fc.boolean(), + copyCondition: fc.boolean(), +}).map(op => { + const original = op.statement1; + const modified = Object.create(original, {}); + + if (op.copySid) { modified.Sid = op.statement2.Sid; } + if (op.copyEffect) { modified.Effect = op.statement2.Effect; } + if (op.copyResource) { modified.Resource = op.statement2.Resource; modified.NotResource = op.statement2.NotResource; } + if (op.copyAction) { modified.Action = op.statement2.Action; modified.NotAction = op.statement2.NotAction; } + if (op.copyPrincipal) { modified.Principal = op.statement2.Principal; modified.NotPrincipal = op.statement2.NotPrincipal; } + if (op.copyCondition) { modified.Condition = op.statement2.Condition; } + + return { statement1: original, statement2: modified }; +}); \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/test/network/test.detect-changes.ts b/packages/@aws-cdk/cloudformation-diff/test/network/test.detect-changes.ts new file mode 100644 index 0000000000000..c5b9e8cf1e86d --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/network/test.detect-changes.ts @@ -0,0 +1,81 @@ +import { Test } from 'nodeunit'; +import { diffTemplate } from '../../lib'; +import { resource, template } from '../util'; + +export = { + 'detect addition of all types of rules'(test: Test) { + // WHEN + const diff = diffTemplate({}, template({ + SG: resource('AWS::EC2::SecurityGroup', { + SecurityGroupIngress: [ + { + CidrIp: '1.2.3.4/8', + FromPort: 80, + ToPort: 80, + IpProtocol: 'tcp', + } + ], + SecurityGroupEgress: [ + { + DestinationSecurityGroupId: { 'Fn::GetAtt': ['ThatOtherGroup', 'GroupId'] }, + FromPort: 80, + ToPort: 80, + IpProtocol: 'tcp', + } + ], + }), + InRule: resource('AWS::EC2::SecurityGroupIngress', { + GroupId: { 'Fn::GetAtt': ['SG', 'GroupId'] }, + FromPort: -1, + ToPort: -1, + IpProtocol: 'icmp', + SourcePrefixListId: 'pl-1234', + }), + OutRule: resource('AWS::EC2::SecurityGroupEgress', { + GroupId: { 'Fn::GetAtt': ['SG', 'GroupId'] }, + FromPort: -1, + ToPort: -1, + IpProtocol: 'udp', + CidrIp: '7.8.9.0/24', + }), + })); + + // THEN + test.deepEqual(diff.securityGroupChanges.toJson(), { + ingressRuleAdditions: [ + { + groupId: '${SG.GroupId}', + ipProtocol: 'tcp', + fromPort: 80, + toPort: 80, + peer: { kind: 'cidr-ip', ip: '1.2.3.4/8' } + }, + { + groupId: '${SG.GroupId}', + ipProtocol: 'icmp', + fromPort: -1, + toPort: -1, + peer: { kind: 'prefix-list', prefixListId: 'pl-1234' } + } + ], + egressRuleAdditions: [ + { + groupId: '${SG.GroupId}', + ipProtocol: 'tcp', + fromPort: 80, + toPort: 80, + peer: { kind: 'security-group', securityGroupId: '${ThatOtherGroup.GroupId}' } + }, + { + groupId: '${SG.GroupId}', + ipProtocol: 'udp', + fromPort: -1, + toPort: -1, + peer: { kind: 'cidr-ip', ip: '7.8.9.0/24' } + } + ] + }); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/cloudformation-diff/test/network/test.rule.ts b/packages/@aws-cdk/cloudformation-diff/test/network/test.rule.ts new file mode 100644 index 0000000000000..c94113f9a5aca --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/network/test.rule.ts @@ -0,0 +1,120 @@ +import fc = require('fast-check'); +import { Test } from 'nodeunit'; +import { SecurityGroupRule } from '../../lib/network/security-group-rule'; + +export = { + 'can parse cidr-ip'(test: Test) { + const rule = new SecurityGroupRule({ + GroupId: 'sg-1234', + IpProtocol: 'tcp', + FromPort: 10, + ToPort: 20, + CidrIp: '1.2.3.4/8', + }); + + test.equal(rule.groupId, 'sg-1234'); + test.equal(rule.ipProtocol, 'tcp'); + test.equal(rule.fromPort, 10); + test.equal(rule.toPort, 20); + + const peer = rule.peer!; + if (peer.kind !== 'cidr-ip') { throw new Error('Fail'); } + test.equal(peer.ip, '1.2.3.4/8'); + + test.done(); + }, + + 'can parse cidr-ip 6'(test: Test) { + const rule = new SecurityGroupRule({ + CidrIpv6: '::0/0' + }); + + const peer = rule.peer!; + if (peer.kind !== 'cidr-ip') { throw new Error('Fail'); } + test.equal(peer.ip, '::0/0'); + + test.done(); + }, + + 'can parse securityGroup'(test: Test) { + for (const key of ['DestinationSecurityGroupId', 'SourceSecurityGroupId']) { + const rule = new SecurityGroupRule({ + [key]: 'sg-1234', + }); + + const peer = rule.peer!; + if (peer.kind !== 'security-group') { throw new Error('Fail'); } + test.equal(peer.securityGroupId, 'sg-1234'); + } + + test.done(); + }, + + 'can parse prefixlist'(test: Test) { + for (const key of ['DestinationPrefixListId', 'SourcePrefixListId']) { + const rule = new SecurityGroupRule({ + [key]: 'pl-1', + }); + + const peer = rule.peer!; + if (peer.kind !== 'prefix-list') { throw new Error('Fail'); } + test.equal(peer.prefixListId, 'pl-1'); + } + + test.done(); + }, + + 'equality is reflexive'(test: Test) { + fc.assert(fc.property( + arbitraryRule, (statement) => { + return new SecurityGroupRule(statement).equal(new SecurityGroupRule(statement)); + } + )); + test.done(); + }, + + 'equality is symmetric'(test: Test) { + fc.assert(fc.property( + twoArbitraryRules, (s) => { + const a = new SecurityGroupRule(s.rule1); + const b = new SecurityGroupRule(s.rule2); + + fc.pre(a.equal(b)); + return b.equal(a); + } + )); + test.done(); + }, +}; + +const arbitraryRule = fc.record({ + IpProtocol: fc.constantFrom('tcp', 'udp', 'icmp'), + FromPort: fc.integer(80, 81), + ToPort: fc.integer(81, 82), + CidrIp: fc.constantFrom('0.0.0.0/0', '1.2.3.4/8', undefined, undefined), + DestinationSecurityGroupId: fc.constantFrom('sg-1234', undefined), + DestinationPrefixListId: fc.constantFrom('pl-1', undefined), +}); + +const twoArbitraryRules = fc.record({ + rule1: arbitraryRule, + rule2: arbitraryRule, + copyIp: fc.boolean(), + copyFromPort: fc.boolean(), + copyToPort : fc.boolean(), + copyCidrIp: fc.boolean(), + copySecurityGroupId: fc.boolean(), + copyPrefixListId: fc.boolean(), +}).map(op => { + const original = op.rule1; + const modified = Object.create(original, {}); + + if (op.copyIp) { modified.IpProtocol = op.rule2.IpProtocol; } + if (op.copyFromPort) { modified.FromPort = op.rule2.FromPort; } + if (op.copyToPort) { modified.ToPort = op.rule2.ToPort; } + if (op.copyCidrIp) { modified.CidrIp = op.rule2.CidrIp; } + if (op.copySecurityGroupId) { modified.DestinationSecurityGroupId = op.rule2.DestinationSecurityGroupId; } + if (op.copyPrefixListId) { modified.DestinationPrefixListId = op.rule2.DestinationPrefixListId; } + + return { rule1: original, rule2: modified }; +}); \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/test/test.diff-template.ts b/packages/@aws-cdk/cloudformation-diff/test/test.diff-template.ts index c4e4d2e1f8a42..01cdc3663dcaf 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/test.diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/test.diff-template.ts @@ -142,7 +142,7 @@ exports.diffTemplate = { const difference = differences.resources.changes.BucketResource; test.notStrictEqual(difference, undefined, 'the difference is on the BucketResource logical ID'); test.equal(difference && difference.oldResourceType, 'AWS::S3::Bucket', 'the difference reports the resource type'); - test.deepEqual(difference && difference.propertyChanges, + test.deepEqual(difference && difference.propertyUpdates, { BucketName: { oldValue: bucketName, newValue: newBucketName, changeImpact: ResourceImpact.WILL_REPLACE } }, 'the difference reports property-level changes'); test.done(); @@ -210,7 +210,7 @@ exports.diffTemplate = { const difference = differences.resources.changes.BucketResource; test.notStrictEqual(difference, undefined, 'the difference is on the BucketResource logical ID'); test.equal(difference && difference.oldResourceType, 'AWS::S3::Bucket', 'the difference reports the resource type'); - test.deepEqual(difference && difference.propertyChanges, + test.deepEqual(difference && difference.propertyUpdates, { BucketName: { oldValue: bucketName, newValue: undefined, changeImpact: ResourceImpact.WILL_REPLACE } }, 'the difference reports property-level changes'); test.done(); @@ -249,7 +249,7 @@ exports.diffTemplate = { const difference = differences.resources.changes.BucketResource; test.notStrictEqual(difference, undefined, 'the difference is on the BucketResource logical ID'); test.equal(difference && difference.oldResourceType, 'AWS::S3::Bucket', 'the difference reports the resource type'); - test.deepEqual(difference && difference.propertyChanges, + test.deepEqual(difference && difference.propertyUpdates, { BucketName: { oldValue: undefined, newValue: bucketName, changeImpact: ResourceImpact.WILL_REPLACE } }, 'the difference reports property-level changes'); test.done(); diff --git a/packages/@aws-cdk/cloudformation-diff/test/test.render-intrinsics.ts b/packages/@aws-cdk/cloudformation-diff/test/test.render-intrinsics.ts new file mode 100644 index 0000000000000..f0c012cd8b786 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/test.render-intrinsics.ts @@ -0,0 +1,66 @@ +import { Test } from 'nodeunit'; +import { renderIntrinsics } from '../lib/render-intrinsics'; + +export = { + 'resolves Ref'(test: Test) { + test.equals( + renderIntrinsics({ Ref: 'SomeLogicalId' }), + '${SomeLogicalId}' + ); + test.done(); + }, + + 'resolves Fn::GetAtt'(test: Test) { + test.equals( + renderIntrinsics({ 'Fn::GetAtt': ['SomeLogicalId', 'Attribute'] }), + '${SomeLogicalId.Attribute}' + ); + test.done(); + }, + + 'resolves Fn::Join'(test: Test) { + test.equals( + renderIntrinsics({ 'Fn::Join': ['/', ['a', 'b', 'c']] }), + 'a/b/c' + ); + + test.done(); + }, + + 'does not resolve Fn::Join if the second argument is not a list literal'(test: Test) { + test.equals( + renderIntrinsics({ 'Fn::Join': ['/', { Ref: 'ListParameter' }] }), + '{"Fn::Join":["/","${ListParameter}"]}' + ); + + test.done(); + }, + + 'deep resolves intrinsics in object'(test: Test) { + test.deepEqual( + renderIntrinsics({ + Deeper1: { Ref: 'SomeLogicalId' }, + Deeper2: 'Do not replace', + }), + { + Deeper1: '${SomeLogicalId}', + Deeper2: 'Do not replace', + } + ); + test.done(); + }, + + 'deep resolves intrinsics in array'(test: Test) { + test.deepEqual( + renderIntrinsics([ + { Ref: 'SomeLogicalId' }, + 'Do not replace', + ]), + [ + '${SomeLogicalId}', + 'Do not replace', + ] + ); + test.done(); + }, +}; diff --git a/packages/@aws-cdk/cloudformation-diff/test/util.ts b/packages/@aws-cdk/cloudformation-diff/test/util.ts new file mode 100644 index 0000000000000..4d2a3bb39f391 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/util.ts @@ -0,0 +1,22 @@ +export function template(resources: {[key: string]: any}) { + return { Resources: resources }; +} + +export function resource(type: string, properties: {[key: string]: any}) { + return { Type: type, Properties: properties }; +} + +export function role(properties: {[key: string]: any}) { + return resource('AWS::IAM::Role', properties); +} + +export function policy(properties: {[key: string]: any}) { + return resource('AWS::IAM::Policy', properties); +} + +export function poldoc(...statements: any[]) { + return { + Version: "2012-10-17", + Statement: statements + }; +} diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 74b7c47338ad9..fdd95accf1ebd 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -10,7 +10,7 @@ import yargs = require('yargs'); import { bootstrapEnvironment, deployStack, destroyStack, loadToolkitInfo, Mode, SDK } from '../lib'; import { environmentsFromDescriptors, globEnvironmentsFromStacks } from '../lib/api/cxapp/environments'; import { AppStacks, listStackNames } from '../lib/api/cxapp/stacks'; -import { printStackDiff } from '../lib/diff'; +import { printSecurityDiff, printStackDiff, RequireApproval } from '../lib/diff'; import { availableInitLanguages, cliInit, printAvailableTemplates } from '../lib/init'; import { interactive } from '../lib/interactive'; import { data, debug, error, highlight, print, setVerbose, success, warning } from '../lib/logging'; @@ -22,6 +22,7 @@ import { VERSION } from '../lib/version'; // tslint:disable-next-line:no-var-requires const promptly = require('promptly'); +const confirm = util.promisify(promptly.confirm); const DEFAULT_TOOLKIT_STACK_NAME = 'CDKToolkit'; @@ -53,6 +54,7 @@ async function parseCommandLineArguments() { .command('bootstrap [ENVIRONMENTS..]', 'Deploys the CDK toolkit stack into an AWS environment', yargs => yargs .option('toolkit-stack-name', { type: 'string', desc: 'the name of the CDK toolkit stack' })) .command('deploy [STACKS..]', 'Deploys the stack(s) named STACKS into your AWS account', yargs => yargs + .option('require-approval', { type: 'string', choices: [RequireApproval.Never, RequireApproval.AnyChange, RequireApproval.Broadening], desc: 'what security-sensitive changes need manual approval' }) .option('toolkit-stack-name', { type: 'string', desc: 'the name of the CDK toolkit stack' })) .command('destroy [STACKS..]', 'Destroy the stack(s) named STACKS', yargs => yargs .option('force', { type: 'boolean', alias: 'f', desc: 'Do not ask for confirmation before destroying the stacks' })) @@ -157,7 +159,7 @@ async function initCommandLine() { return await cliBootstrap(args.ENVIRONMENTS, toolkitStackName, args.roleArn); case 'deploy': - return await cliDeploy(args.STACKS, toolkitStackName, args.roleArn); + return await cliDeploy(args.STACKS, toolkitStackName, args.roleArn, configuration.combined.get(['requireApproval'])); case 'destroy': return await cliDestroy(args.STACKS, args.force, args.roleArn); @@ -286,7 +288,9 @@ async function initCommandLine() { return 0; // exit-code } - async function cliDeploy(stackNames: string[], toolkitStackName: string, roleArn: string | undefined) { + async function cliDeploy(stackNames: string[], toolkitStackName: string, roleArn: string | undefined, requireApproval: RequireApproval) { + if (requireApproval === undefined) { requireApproval = RequireApproval.Broadening; } + const stacks = await appStacks.selectStacks(...stackNames); renames.validateSelectedStacks(stacks); @@ -299,6 +303,14 @@ async function initCommandLine() { const toolkitInfo = await loadToolkitInfo(stack.environment, aws, toolkitStackName); const deployName = renames.finalName(stack.name); + if (requireApproval !== RequireApproval.Never) { + const currentTemplate = await readCurrentTemplate(stack); + if (printSecurityDiff(currentTemplate, stack, requireApproval)) { + const confirmed = await confirm(`Do you wish to deploy these changes (y/n)?`); + if (!confirmed) { throw new Error('Aborted by user'); } + } + } + if (deployName !== stack.name) { print('%s: deploying... (was %s)', colors.bold(deployName), colors.bold(stack.name)); } else { @@ -338,7 +350,7 @@ async function initCommandLine() { if (!force) { // tslint:disable-next-line:max-line-length - const confirmed = await util.promisify(promptly.confirm)(`Are you sure you want to delete: ${colors.blue(stacks.map(s => s.name).join(', '))} (y/n)?`); + const confirmed = await confirm(`Are you sure you want to delete: ${colors.blue(stacks.map(s => s.name).join(', '))} (y/n)?`); if (!confirmed) { return; } @@ -376,8 +388,10 @@ async function initCommandLine() { const fileContent = await fs.readFile(templatePath, { encoding: 'UTF-8' }); return parseTemplate(fileContent); } else { - const cfn = await aws.cloudFormation(stack.environment, Mode.ForReading); const stackName = renames.finalName(stack.name); + debug(`Reading existing template for stack ${stackName}.`); + + const cfn = await aws.cloudFormation(stack.environment, Mode.ForReading); try { const response = await cfn.getTemplate({ StackName: stackName }).promise(); return (response.TemplateBody && parseTemplate(response.TemplateBody)) || {}; @@ -436,6 +450,7 @@ async function initCommandLine() { plugin: argv.plugin, toolkitStackName: argv.toolkitStackName, versionReporting: argv.versionReporting, + requireApproval: argv.requireApproval, pathMetadata: argv.pathMetadata, }); } diff --git a/packages/aws-cdk/integ-tests/app/app.js b/packages/aws-cdk/integ-tests/app/app.js index c63e71e9208e6..8da2b8aa6507b 100644 --- a/packages/aws-cdk/integ-tests/app/app.js +++ b/packages/aws-cdk/integ-tests/app/app.js @@ -1,4 +1,5 @@ const cdk = require('@aws-cdk/cdk'); +const iam = require('@aws-cdk/aws-iam'); const sns = require('@aws-cdk/aws-sns'); class MyStack extends cdk.Stack { @@ -19,9 +20,22 @@ class YourStack extends cdk.Stack { } } +class IamStack extends cdk.Stack { + constructor(parent, id) { + super(parent, id); + + new iam.Role(this, 'SomeRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazon.aws.com') + }); + } +} + const app = new cdk.App(); +// Deploy all does a wildcard cdk-toolkit-integration-test-* new MyStack(app, 'cdk-toolkit-integration-test-1'); new YourStack(app, 'cdk-toolkit-integration-test-2'); +// Not included in wildcard +new IamStack(app, 'cdk-toolkit-integration-iam-test'); app.run(); diff --git a/packages/aws-cdk/integ-tests/common.bash b/packages/aws-cdk/integ-tests/common.bash index 9ff7aaf1cd90f..cdb0d0baa4139 100644 --- a/packages/aws-cdk/integ-tests/common.bash +++ b/packages/aws-cdk/integ-tests/common.bash @@ -37,6 +37,7 @@ function setup() { ( cd node_modules/@aws-cdk ln -s ${scriptdir}/../../@aws-cdk/aws-sns + ln -s ${scriptdir}/../../@aws-cdk/aws-iam ln -s ${scriptdir}/../../@aws-cdk/cdk ) } @@ -51,7 +52,7 @@ function assert_diff() { local actual=$2 local expected=$3 - diff ${actual} ${expected} || { + diff -w ${actual} ${expected} || { echo echo "+-----------" echo "| expected:" @@ -95,3 +96,7 @@ function assert_lines() { fail "response has ${lines} lines and we expected ${expected} lines to be returned" fi } + +function strip_color_codes() { + perl -pe 's/\e\[?.*?[\@-~]//g' +} diff --git a/packages/aws-cdk/integ-tests/test-cdk-deploy-all.sh b/packages/aws-cdk/integ-tests/test-cdk-deploy-all.sh index a91c2e1677cd6..dc712453b5594 100755 --- a/packages/aws-cdk/integ-tests/test-cdk-deploy-all.sh +++ b/packages/aws-cdk/integ-tests/test-cdk-deploy-all.sh @@ -6,7 +6,7 @@ source ${scriptdir}/common.bash setup -stack_arns=$(cdk deploy) +stack_arns=$(cdk deploy cdk-toolkit-integration-test-\*) echo "Stack deployed successfully" # verify that we only deployed a single stack (there's a single ARN in the output) @@ -18,7 +18,6 @@ if [ "${lines}" -ne 2 ]; then fail "cdk deploy returned ${lines} arns and we expected 2" fi -cdk destroy -f cdk-toolkit-integration-test-1 -cdk destroy -f cdk-toolkit-integration-test-2 +cdk destroy -f cdk-toolkit-integration-test-\* echo "✅ success" diff --git a/packages/aws-cdk/integ-tests/test-cdk-iam-diff.sh b/packages/aws-cdk/integ-tests/test-cdk-iam-diff.sh new file mode 100755 index 0000000000000..acc7f9d01320c --- /dev/null +++ b/packages/aws-cdk/integ-tests/test-cdk-iam-diff.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail +scriptdir=$(cd $(dirname $0) && pwd) +source ${scriptdir}/common.bash +# ---------------------------------------------------------- + +setup + +function nonfailing_diff() { + ( cdk diff $1 2>&1 || true ) | strip_color_codes +} + +assert "nonfailing_diff cdk-toolkit-integration-iam-test" <(xs: T[]): Array<[number, T]> { i += 1; } return ret; -} \ No newline at end of file +} diff --git a/packages/aws-cdk/lib/diff.ts b/packages/aws-cdk/lib/diff.ts index da9ca8bb2642b..2cf86c45a3017 100644 --- a/packages/aws-cdk/lib/diff.ts +++ b/packages/aws-cdk/lib/diff.ts @@ -1,7 +1,7 @@ import cfnDiff = require('@aws-cdk/cloudformation-diff'); import cxapi = require('@aws-cdk/cx-api'); import colors = require('colors/safe'); -import { print } from './logging'; +import { print, warning } from './logging'; /** * Pretty-prints the differences between two template states to the console. @@ -33,6 +33,48 @@ export function printStackDiff(oldTemplate: any, newTemplate: cxapi.SynthesizedS return diff.count; } +export enum RequireApproval { + Never = 'never', + + AnyChange = 'any-change', + + Broadening = 'broadening' +} + +/** + * Print the security changes of this diff, if the change is impactful enough according to the approval level + * + * Returns true if the changes are prompt-worthy, false otherwise. + */ +export function printSecurityDiff(oldTemplate: any, newTemplate: cxapi.SynthesizedStack, requireApproval: RequireApproval): boolean { + const diff = cfnDiff.diffTemplate(oldTemplate, newTemplate.template); + + if (difRequiresApproval(diff, requireApproval)) { + // tslint:disable-next-line:max-line-length + warning(`This deployment will make potentially sensitive changes according to your current security approval level (--require-approval ${requireApproval}).`); + warning(`Please confirm you intend to make the following modifications:\n`); + + cfnDiff.formatSecurityChanges(process.stderr, diff, buildLogicalToPathMap(newTemplate)); + return true; + } + return false; +} + +/** + * Return whether the diff has security-impacting changes that need confirmation + * + * TODO: Filter the security impact determination based off of an enum that allows + * us to pick minimum "severities" to alert on. + */ +function difRequiresApproval(diff: cfnDiff.TemplateDiff, requireApproval: RequireApproval) { + switch (requireApproval) { + case RequireApproval.Never: return false; + case RequireApproval.AnyChange: return diff.permissionsAnyChanges; + case RequireApproval.Broadening: return diff.permissionsBroadened; + default: throw new Error(`Unrecognized approval level: ${requireApproval}`); + } +} + function buildLogicalToPathMap(template: cxapi.SynthesizedStack) { const map: { [id: string]: string } = {}; for (const path of Object.keys(template.metadata)) { diff --git a/packages/aws-cdk/lib/util/index.ts b/packages/aws-cdk/lib/util/index.ts index d3781a0fcc33e..a9a8af258b0f2 100644 --- a/packages/aws-cdk/lib/util/index.ts +++ b/packages/aws-cdk/lib/util/index.ts @@ -1,3 +1,4 @@ export * from './arrays'; export * from './objects'; export * from './types'; +export * from './tables'; diff --git a/packages/aws-cdk/lib/util/tables.ts b/packages/aws-cdk/lib/util/tables.ts new file mode 100644 index 0000000000000..56a42f13fb105 --- /dev/null +++ b/packages/aws-cdk/lib/util/tables.ts @@ -0,0 +1,13 @@ +import Table = require('cli-table'); + +export interface RenderTableOptions { + colWidths?: number[]; +} + +export function renderTable(cells: string[][], options: RenderTableOptions = {}): string { + const head = cells.splice(0, 1)[0]; + + const table = new Table({ head, style: { head: [] }, colWidths: options.colWidths }); + table.push(...cells); + return table.toString(); +} diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index 5a1378c9dd2f0..51128db6040ba 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -32,12 +32,12 @@ "license": "Apache-2.0", "devDependencies": { "@types/archiver": "^2.1.2", + "@types/cli-table": "^0.3.0", "@types/fs-extra": "^5.0.4", "@types/minimatch": "^3.0.3", "@types/mockery": "^1.4.29", "@types/request": "^2.47.1", "@types/semver": "^5.5.0", - "@types/table": "^4.0.5", "@types/uuid": "^3.4.3", "@types/yaml": "^1.0.0", "@types/yargs": "^8.0.3", @@ -58,11 +58,11 @@ "json-diff": "^0.3.1", "minimatch": ">=3.0", "promptly": "^0.2.0", + "cli-table": "^0.3.1", "proxy-agent": "^3.0.1", "request": "^2.83.0", "semver": "^5.5.0", "source-map-support": "^0.5.6", - "table": "^5.1.0", "yaml": "^1.0.1", "yargs": "^9.0.1" }, diff --git a/tools/cdk-integ-tools/bin/cdk-integ.ts b/tools/cdk-integ-tools/bin/cdk-integ.ts index c0189c8e5804a..e076756c4c23b 100644 --- a/tools/cdk-integ-tools/bin/cdk-integ.ts +++ b/tools/cdk-integ-tools/bin/cdk-integ.ts @@ -34,7 +34,7 @@ async function main() { } try { - await test.invoke([ ...args, 'deploy' ], { verbose: argv.verbose }); // Note: no context, so use default user settings! + await test.invoke([ ...args, 'deploy', '--prompt', 'never' ], { verbose: argv.verbose }); // Note: no context, so use default user settings! console.error(`Success! Writing out reference synth.`);