diff --git a/.projenrc.ts b/.projenrc.ts index c0a776eaa..ee4f3bd38 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -680,7 +680,9 @@ const tmpToolkitHelpers = configureProject( ], deps: [ cloudAssemblySchema.name, + cloudFormationDiff, 'archiver', + 'chalk@4', 'glob', 'semver', 'uuid', diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json index ce84472f3..d638a847a 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json @@ -105,10 +105,19 @@ "name": "@aws-cdk/cloud-assembly-schema", "type": "runtime" }, + { + "name": "@aws-cdk/cloudformation-diff", + "type": "runtime" + }, { "name": "archiver", "type": "runtime" }, + { + "name": "chalk", + "version": "4", + "type": "runtime" + }, { "name": "glob", "type": "runtime" diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json index 1338fe2c0..4ef457ac1 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json @@ -77,7 +77,7 @@ "name": "gather-versions", "steps": [ { - "exec": "node -e \"require(require.resolve('cdklabs-projen-project-types/lib/yarn/gather-versions.exec.js')).cliMain()\" ", + "exec": "node -e \"require(require.resolve('cdklabs-projen-project-types/lib/yarn/gather-versions.exec.js')).cliMain()\" @aws-cdk/cloudformation-diff=major", "receiveArgs": true } ] diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/package.json b/packages/@aws-cdk/tmp-toolkit-helpers/package.json index e3d56afc9..88d36ad54 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/package.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/package.json @@ -56,7 +56,9 @@ }, "dependencies": { "@aws-cdk/cloud-assembly-schema": "^0.0.0", + "@aws-cdk/cloudformation-diff": "^0.0.0", "archiver": "^7.0.1", + "chalk": "4", "glob": "^11.0.1", "semver": "^7.7.1", "uuid": "^11.1.0", diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/index.ts new file mode 100644 index 000000000..91d567d4e --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/index.ts @@ -0,0 +1,2 @@ +export * from './nested-stack-templates'; +export * from './stack-helpers'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/nested-stack-templates.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/nested-stack-templates.ts new file mode 100644 index 000000000..7080d893d --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/nested-stack-templates.ts @@ -0,0 +1,10 @@ +import type { Template } from './stack-helpers'; + +export interface NestedStackTemplates { + readonly physicalName: string | undefined; + readonly deployedTemplate: Template; + readonly generatedTemplate: Template; + readonly nestedStackTemplates: { + [nestedStackLogicalId: string]: NestedStackTemplates; + }; +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/stack-helpers.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/stack-helpers.ts new file mode 100644 index 000000000..9b2492639 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/stack-helpers.ts @@ -0,0 +1,11 @@ +export interface Template { + Parameters?: Record; + [section: string]: any; +} + +export interface TemplateParameter { + Type: string; + Default?: any; + Description?: string; + [key: string]: any; +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff.ts new file mode 100644 index 000000000..ab69da2ff --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff.ts @@ -0,0 +1,296 @@ +import { format } from 'node:util'; +import { Writable } from 'stream'; +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import { + type DescribeChangeSetOutput, + type TemplateDiff, + fullDiff, + formatSecurityChanges, + formatDifferences, + mangleLikeCloudFormation, +} from '@aws-cdk/cloudformation-diff'; +import type * as cxapi from '@aws-cdk/cx-api'; +import * as chalk from 'chalk'; + +import { RequireApproval } from '../../api/require-approval'; +import { ToolkitError } from '../../api/toolkit-error'; +import type { NestedStackTemplates } from '../cloudformation/nested-stack-templates'; +import type { IoHelper } from '../io/private'; +import { IoDefaultMessages } from '../io/private'; + +/* + * Custom writable stream that collects text into a string buffer. + * Used on classes that take in and directly write to a stream, but + * we intend to capture the output rather than print. + */ +export class StringWriteStream extends Writable { + private buffer: string[] = []; + + constructor() { + super(); + } + + _write(chunk: any, _encoding: string, callback: (error?: Error | null) => void): void { + this.buffer.push(chunk.toString()); + callback(); + } + + toString(): string { + return this.buffer.join(''); + } +} + +/** + * Output of formatSecurityDiff + */ +export interface FormatSecurityDiffOutput { + /** + * Complete formatted security diff, if it is prompt-worthy + */ + readonly formattedDiff?: string; +} + +/** + * Formats the security changes of this diff, if the change is impactful enough according to the approval level + * + * Returns the diff if the changes are prompt-worthy, an empty object otherwise. + */ +export function formatSecurityDiff( + ioHelper: IoHelper, + oldTemplate: any, + newTemplate: cxapi.CloudFormationStackArtifact, + requireApproval: RequireApproval, + stackName?: string, + changeSet?: DescribeChangeSetOutput, +): FormatSecurityDiffOutput { + const ioDefaultHelper = new IoDefaultMessages(ioHelper); + + const diff = fullDiff(oldTemplate, newTemplate.template, changeSet); + + if (diffRequiresApproval(diff, requireApproval)) { + ioDefaultHelper.info(format('Stack %s\n', chalk.bold(stackName))); + + // eslint-disable-next-line max-len + ioDefaultHelper.warning(`This deployment will make potentially sensitive changes according to your current security approval level (--require-approval ${requireApproval}).`); + ioDefaultHelper.warning('Please confirm you intend to make the following modifications:\n'); + + // The security diff is formatted via `Formatter`, which takes in a stream + // and sends its output directly to that stream. To faciliate use of the + // global CliIoHost, we create our own stream to capture the output of + // `Formatter` and return the output as a string for the consumer of + // `formatSecurityDiff` to decide what to do with it. + const stream = new StringWriteStream(); + try { + // formatSecurityChanges updates the stream with the formatted security diff + formatSecurityChanges(stream, diff, buildLogicalToPathMap(newTemplate)); + } finally { + stream.end(); + } + // store the stream containing a formatted stack diff + const formattedDiff = stream.toString(); + return { formattedDiff }; + } + return {}; +} + +/** + * Output of formatStackDiff + */ +export interface FormatStackDiffOutput { + /** + * Number of stacks with diff changes + */ + readonly numStacksWithChanges: number; + + /** + * Complete formatted diff + */ + readonly formattedDiff: string; +} + +/** + * Formats the differences between two template states and returns it as a string. + * + * @param oldTemplate the old/current state of the stack. + * @param newTemplate the new/target state of the stack. + * @param strict do not filter out AWS::CDK::Metadata or Rules + * @param context lines of context to use in arbitrary JSON diff + * @param quiet silences \'There were no differences\' messages + * + * @returns the formatted diff, and the number of stacks in this stack tree that have differences, including the top-level root stack + */ +export function formatStackDiff( + ioHelper: IoHelper, + oldTemplate: any, + newTemplate: cxapi.CloudFormationStackArtifact, + strict: boolean, + context: number, + quiet: boolean, + stackName?: string, + changeSet?: DescribeChangeSetOutput, + isImport?: boolean, + nestedStackTemplates?: { [nestedStackLogicalId: string]: NestedStackTemplates }): FormatStackDiffOutput { + const ioDefaultHelper = new IoDefaultMessages(ioHelper); + + let diff = fullDiff(oldTemplate, newTemplate.template, changeSet, isImport); + + // The stack diff is formatted via `Formatter`, which takes in a stream + // and sends its output directly to that stream. To faciliate use of the + // global CliIoHost, we create our own stream to capture the output of + // `Formatter` and return the output as a string for the consumer of + // `formatStackDiff` to decide what to do with it. + const stream = new StringWriteStream(); + + let numStacksWithChanges = 0; + let formattedDiff = ''; + let filteredChangesCount = 0; + try { + // must output the stack name if there are differences, even if quiet + if (stackName && (!quiet || !diff.isEmpty)) { + stream.write(format('Stack %s\n', chalk.bold(stackName))); + } + + if (!quiet && isImport) { + stream.write('Parameters and rules created during migration do not affect resource configuration.\n'); + } + + // detect and filter out mangled characters from the diff + if (diff.differenceCount && !strict) { + const mangledNewTemplate = JSON.parse(mangleLikeCloudFormation(JSON.stringify(newTemplate.template))); + const mangledDiff = fullDiff(oldTemplate, mangledNewTemplate, changeSet); + filteredChangesCount = Math.max(0, diff.differenceCount - mangledDiff.differenceCount); + if (filteredChangesCount > 0) { + diff = mangledDiff; + } + } + + // filter out 'AWS::CDK::Metadata' resources from the template + // filter out 'CheckBootstrapVersion' rules from the template + if (!strict) { + obscureDiff(diff); + } + + if (!diff.isEmpty) { + numStacksWithChanges++; + + // formatDifferences updates the stream with the formatted stack diff + formatDifferences(stream, diff, { + ...logicalIdMapFromTemplate(oldTemplate), + ...buildLogicalToPathMap(newTemplate), + }, context); + + // store the stream containing a formatted stack diff + formattedDiff = stream.toString(); + } else if (!quiet) { + ioDefaultHelper.info(chalk.green('There were no differences')); + } + } finally { + stream.end(); + } + + if (filteredChangesCount > 0) { + ioDefaultHelper.info(chalk.yellow(`Omitted ${filteredChangesCount} changes because they are likely mangled non-ASCII characters. Use --strict to print them.`)); + } + + for (const nestedStackLogicalId of Object.keys(nestedStackTemplates ?? {})) { + if (!nestedStackTemplates) { + break; + } + const nestedStack = nestedStackTemplates[nestedStackLogicalId]; + + (newTemplate as any)._template = nestedStack.generatedTemplate; + const nextDiff = formatStackDiff( + ioHelper, + nestedStack.deployedTemplate, + newTemplate, + strict, + context, + quiet, + nestedStack.physicalName ?? nestedStackLogicalId, + undefined, + isImport, + nestedStack.nestedStackTemplates, + ); + numStacksWithChanges += nextDiff.numStacksWithChanges; + formattedDiff += nextDiff.formattedDiff; + } + + return { + numStacksWithChanges, + formattedDiff, + }; +} + +/** + * 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 diffRequiresApproval(diff: TemplateDiff, requireApproval: RequireApproval) { + switch (requireApproval) { + case RequireApproval.NEVER: return false; + case RequireApproval.ANY_CHANGE: return diff.permissionsAnyChanges; + case RequireApproval.BROADENING: return diff.permissionsBroadened; + default: throw new ToolkitError(`Unrecognized approval level: ${requireApproval}`); + } +} + +export function buildLogicalToPathMap(stack: cxapi.CloudFormationStackArtifact) { + const map: { [id: string]: string } = {}; + for (const md of stack.findMetadataByType(cxschema.ArtifactMetadataEntryType.LOGICAL_ID)) { + map[md.data as string] = md.path; + } + return map; +} + +export function logicalIdMapFromTemplate(template: any) { + const ret: Record = {}; + + for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) { + const path = (resource as any)?.Metadata?.['aws:cdk:path']; + if (path) { + ret[logicalId] = path; + } + } + return ret; +} + +/** + * Remove any template elements that we don't want to show users. + * This is currently: + * - AWS::CDK::Metadata resource + * - CheckBootstrapVersion Rule + */ +export function obscureDiff(diff: TemplateDiff) { + if (diff.unknown) { + // see https://github.com/aws/aws-cdk/issues/17942 + diff.unknown = diff.unknown.filter(change => { + if (!change) { + return true; + } + if (change.newValue?.CheckBootstrapVersion) { + return false; + } + if (change.oldValue?.CheckBootstrapVersion) { + return false; + } + return true; + }); + } + + if (diff.resources) { + diff.resources = diff.resources.filter(change => { + if (!change) { + return true; + } + if (change.newResourceType === 'AWS::CDK::Metadata') { + return false; + } + if (change.oldResourceType === 'AWS::CDK::Metadata') { + return false; + } + return true; + }); + } +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/index.ts new file mode 100644 index 000000000..a89b256df --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/index.ts @@ -0,0 +1 @@ +export * from './diff'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts index faede5fe1..8ef7e1b26 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts @@ -1,4 +1,6 @@ export * from './cloud-assembly'; +export * from './cloudformation'; +export * from './diff'; export * from './io'; export * from './toolkit-error'; export * from './require-approval'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts new file mode 100644 index 000000000..b5662b6fb --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts @@ -0,0 +1,288 @@ +import { fullDiff, formatSecurityChanges, formatDifferences, mangleLikeCloudFormation } from '@aws-cdk/cloudformation-diff'; +import type * as cxapi from '@aws-cdk/cx-api'; +import * as chalk from 'chalk'; +import { formatSecurityDiff, formatStackDiff } from '../../../src/api/diff/diff'; +import { IoHelper, IoDefaultMessages } from '../../../src/api/io/private'; +import { RequireApproval } from '../../../src/api/require-approval'; + +jest.mock('../../../src/api/io/private/messages', () => ({ + IoDefaultMessages: jest.fn(), +})); + +describe('formatStackDiff', () => { + let mockIoHelper: IoHelper; + let mockNewTemplate: cxapi.CloudFormationStackArtifact; + let mockIoDefaultMessages: any; + + beforeEach(() => { + const mockNotify = jest.fn().mockResolvedValue(undefined); + const mockRequestResponse = jest.fn().mockResolvedValue(undefined); + + mockIoHelper = IoHelper.fromIoHost( + { notify: mockNotify, requestResponse: mockRequestResponse }, + 'diff', + ); + + mockIoDefaultMessages = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }; + + jest.spyOn(mockIoHelper, 'notify').mockImplementation(() => Promise.resolve()); + jest.spyOn(mockIoHelper, 'requestResponse').mockImplementation(() => Promise.resolve()); + + (IoDefaultMessages as jest.Mock).mockImplementation(() => mockIoDefaultMessages); + + mockNewTemplate = { + template: { + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'XXXXXXXXXXX', + S3Key: 'some-key', + }, + Handler: 'index.handler', + Runtime: 'nodejs14.x', + }, + }, + }, + }, + templateFile: 'template.json', + stackName: 'test-stack', + findMetadataByType: () => [], + } as any; + }); + + test('returns no changes when templates are identical', () => { + // WHEN + const result = formatStackDiff( + mockIoHelper, + {}, + { + template: {}, + templateFile: 'template.json', + stackName: 'test-stack', + findMetadataByType: () => [], + } as any, + false, + 3, + false, + 'test-stack', + ); + + // THEN + expect(result.numStacksWithChanges).toBe(0); + expect(result.formattedDiff).toBe(''); + expect(mockIoDefaultMessages.info).toHaveBeenCalledWith(expect.stringContaining('no differences')); + }); + + test('formats differences when changes exist', () => { + // WHEN + const result = formatStackDiff( + mockIoHelper, + {}, + mockNewTemplate, + false, + 3, + false, + 'test-stack', + ); + + // THEN + expect(result.numStacksWithChanges).toBe(1); + expect(result.formattedDiff).toBeDefined(); + const sanitizedDiff = result.formattedDiff!.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').trim(); + expect(sanitizedDiff).toBe( + 'Stack test-stack\n' + + 'Resources\n' + + '[+] AWS::Lambda::Function Func', + ); + }); + + test('handles nested stack templates', () => { + const nestedStackTemplates = { + NestedStack1: { + deployedTemplate: {}, + generatedTemplate: {}, + physicalName: 'nested-stack-1', + nestedStackTemplates: {}, + }, + }; + + // WHEN + const result = formatStackDiff( + mockIoHelper, + {}, + mockNewTemplate, + false, + 3, + false, + 'test-stack', + undefined, + false, + nestedStackTemplates, + ); + + // THEN + expect(result.numStacksWithChanges).toBe(2); + expect(result.formattedDiff).toContain(`Stack ${chalk.bold('test-stack')}`); + expect(result.formattedDiff).toContain(`Stack ${chalk.bold('nested-stack-1')}`); + }); +}); + +describe('formatSecurityDiff', () => { + let mockIoHelper: IoHelper; + let mockNewTemplate: cxapi.CloudFormationStackArtifact; + let mockIoDefaultMessages: any; + + beforeEach(() => { + const mockNotify = jest.fn().mockResolvedValue(undefined); + const mockRequestResponse = jest.fn().mockResolvedValue(undefined); + + mockIoHelper = IoHelper.fromIoHost( + { notify: mockNotify, requestResponse: mockRequestResponse }, + 'diff', + ); + + mockIoDefaultMessages = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }; + + jest.spyOn(mockIoHelper, 'notify').mockImplementation(() => Promise.resolve()); + jest.spyOn(mockIoHelper, 'requestResponse').mockImplementation(() => Promise.resolve()); + + // Mock IoDefaultMessages constructor to return our mock instance + (IoDefaultMessages as jest.Mock).mockImplementation(() => mockIoDefaultMessages); + + mockNewTemplate = { + template: { + Resources: { + Role: { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { + Service: 'lambda.amazonaws.com', + }, + Action: 'sts:AssumeRole', + }], + }, + ManagedPolicyArns: [ + 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + ], + }, + }, + }, + }, + templateFile: 'template.json', + stackName: 'test-stack', + findMetadataByType: () => [], + } as any; + }); + + test('returns empty object when no security changes exist', () => { + // WHEN + const result = formatSecurityDiff( + mockIoHelper, + {}, + { + template: {}, + templateFile: 'template.json', + stackName: 'test-stack', + findMetadataByType: () => [], + } as any, + RequireApproval.BROADENING, + 'test-stack', + ); + + // THEN + expect(result).toEqual({}); + expect(mockIoDefaultMessages.warning).not.toHaveBeenCalled(); + }); + + test('formats diff when permissions are broadened and approval level is BROADENING', () => { + // WHEN + const result = formatSecurityDiff( + mockIoHelper, + {}, + mockNewTemplate, + RequireApproval.BROADENING, + 'test-stack', + ); + + // THEN + expect(result.formattedDiff).toBeDefined(); + const sanitizedDiff = result.formattedDiff!.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').trim(); + expect(sanitizedDiff).toBe( + 'IAM Statement Changes\n' + + '┌───┬─────────────┬────────┬────────────────┬──────────────────────────────┬───────────┐\n' + + '│ │ Resource │ Effect │ Action │ Principal │ Condition │\n' + + '├───┼─────────────┼────────┼────────────────┼──────────────────────────────┼───────────┤\n' + + '│ + │ ${Role.Arn} │ Allow │ sts:AssumeRole │ Service:lambda.amazonaws.com │ │\n' + + '└───┴─────────────┴────────┴────────────────┴──────────────────────────────┴───────────┘\n' + + 'IAM Policy Changes\n' + + '┌───┬──────────┬──────────────────────────────────────────────────────────────────┐\n' + + '│ │ Resource │ Managed Policy ARN │\n' + + '├───┼──────────┼──────────────────────────────────────────────────────────────────┤\n' + + '│ + │ ${Role} │ arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │\n' + + '└───┴──────────┴──────────────────────────────────────────────────────────────────┘\n' + + '(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)', + ); + }); + + test('formats diff for any security change when approval level is ANY_CHANGE', () => { + // WHEN + const result = formatSecurityDiff( + mockIoHelper, + {}, + mockNewTemplate, + RequireApproval.ANY_CHANGE, + 'test-stack', + ); + + // THEN + expect(result.formattedDiff).toBeDefined(); + expect(mockIoDefaultMessages.warning).toHaveBeenCalledWith( + expect.stringContaining('potentially sensitive changes'), + ); + const sanitizedDiff = result.formattedDiff!.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').trim(); + expect(sanitizedDiff).toBe( + 'IAM Statement Changes\n' + + '┌───┬─────────────┬────────┬────────────────┬──────────────────────────────┬───────────┐\n' + + '│ │ Resource │ Effect │ Action │ Principal │ Condition │\n' + + '├───┼─────────────┼────────┼────────────────┼──────────────────────────────┼───────────┤\n' + + '│ + │ ${Role.Arn} │ Allow │ sts:AssumeRole │ Service:lambda.amazonaws.com │ │\n' + + '└───┴─────────────┴────────┴────────────────┴──────────────────────────────┴───────────┘\n' + + 'IAM Policy Changes\n' + + '┌───┬──────────┬──────────────────────────────────────────────────────────────────┐\n' + + '│ │ Resource │ Managed Policy ARN │\n' + + '├───┼──────────┼──────────────────────────────────────────────────────────────────┤\n' + + '│ + │ ${Role} │ arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │\n' + + '└───┴──────────┴──────────────────────────────────────────────────────────────────┘\n' + + '(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)', + ); + }); + + test('returns empty object when approval level is NEVER', () => { + // WHEN + const result = formatSecurityDiff( + mockIoHelper, + {}, + mockNewTemplate, + RequireApproval.NEVER, + 'test-stack', + ); + + // THEN + expect(result).toEqual({}); + expect(mockIoDefaultMessages.warning).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/tsconfig.dev.json b/packages/@aws-cdk/tmp-toolkit-helpers/tsconfig.dev.json index f1aaa8777..c4f3846f5 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/tsconfig.dev.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/tsconfig.dev.json @@ -38,5 +38,9 @@ "exclude": [ "node_modules" ], - "references": [] + "references": [ + { + "path": "../cloudformation-diff" + } + ] } diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/tsconfig.json b/packages/@aws-cdk/tmp-toolkit-helpers/tsconfig.json index 9150afdf2..ba0ead515 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/tsconfig.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/tsconfig.json @@ -36,5 +36,9 @@ "src/**/*.ts" ], "exclude": [], - "references": [] + "references": [ + { + "path": "../cloudformation-diff" + } + ] } diff --git a/packages/aws-cdk/lib/api/cloudformation/nested-stack-helpers.ts b/packages/aws-cdk/lib/api/cloudformation/nested-stack-helpers.ts index 2775634a1..30b09b34f 100644 --- a/packages/aws-cdk/lib/api/cloudformation/nested-stack-helpers.ts +++ b/packages/aws-cdk/lib/api/cloudformation/nested-stack-helpers.ts @@ -5,15 +5,9 @@ import { formatErrorMessage } from '../../util'; import type { SDK } from '../aws-auth'; import { LazyListStackResources, type ListStackResources } from './evaluate-cloudformation-template'; import { CloudFormationStack, type Template } from './stack-helpers'; +import type { NestedStackTemplates } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -export interface NestedStackTemplates { - readonly physicalName: string | undefined; - readonly deployedTemplate: Template; - readonly generatedTemplate: Template; - readonly nestedStackTemplates: { - [nestedStackLogicalId: string]: NestedStackTemplates; - }; -} +export { NestedStackTemplates } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; export interface RootTemplateWithNestedStacks { readonly deployedRootTemplate: Template; diff --git a/packages/aws-cdk/lib/api/cloudformation/stack-helpers.ts b/packages/aws-cdk/lib/api/cloudformation/stack-helpers.ts index 271ae9cd6..77c3bb76b 100644 --- a/packages/aws-cdk/lib/api/cloudformation/stack-helpers.ts +++ b/packages/aws-cdk/lib/api/cloudformation/stack-helpers.ts @@ -1,20 +1,11 @@ import type { Stack, Tag } from '@aws-sdk/client-cloudformation'; +import type { Template } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; import { formatErrorMessage, deserializeStructure } from '../../util'; import type { ICloudFormationClient } from '../aws-auth'; import { StackStatus } from '../stack-events'; -export interface Template { - Parameters?: Record; - [section: string]: any; -} - -export interface TemplateParameter { - Type: string; - Default?: any; - Description?: string; - [key: string]: any; -} +export { Template, TemplateParameter } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; /** * Represents an (existing) Stack in CloudFormation diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index d70ab33fb..4fc6efbdd 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -198,9 +198,10 @@ export class CdkToolkit { if (options.securityOnly) { const securityDiff = formatSecurityDiff( + asIoHelper(this.ioHost, 'diff'), template, stacks.firstStack, - RequireApproval.Broadening, + RequireApproval.BROADENING, ); if (securityDiff.formattedDiff) { info(securityDiff.formattedDiff); @@ -208,6 +209,7 @@ export class CdkToolkit { } } else { const diff = formatStackDiff( + asIoHelper(this.ioHost, 'diff'), template, stacks.firstStack, strict, @@ -278,9 +280,10 @@ export class CdkToolkit { if (options.securityOnly) { const securityDiff = formatSecurityDiff( + asIoHelper(this.ioHost, 'diff'), currentTemplate, stack, - RequireApproval.Broadening, + RequireApproval.BROADENING, stack.displayName, changeSet, ); @@ -290,6 +293,7 @@ export class CdkToolkit { } } else { const diff = formatStackDiff( + asIoHelper(this.ioHost, 'diff'), currentTemplate, stack, strict, @@ -345,7 +349,7 @@ export class CdkToolkit { ...options, }); - const requireApproval = options.requireApproval ?? RequireApproval.Broadening; + const requireApproval = options.requireApproval ?? RequireApproval.BROADENING; const parameterMap = buildParameterMap(options.parameters); @@ -421,9 +425,14 @@ export class CdkToolkit { return; } - if (requireApproval !== RequireApproval.Never) { + if (requireApproval !== RequireApproval.NEVER) { const currentTemplate = await this.props.deployments.readCurrentTemplate(stack); - const securityDiff = formatSecurityDiff(currentTemplate, stack, requireApproval); + const securityDiff = formatSecurityDiff( + asIoHelper(this.ioHost, 'deploy'), + currentTemplate, + stack, + requireApproval, + ); if (securityDiff.formattedDiff) { info(securityDiff.formattedDiff); await askUserConfirmation( @@ -1305,7 +1314,7 @@ export class CdkToolkit { ): Promise { const deployOptions: DeployOptions = { ...options, - requireApproval: RequireApproval.Never, + requireApproval: RequireApproval.NEVER, // if 'watch' is called by invoking 'cdk deploy --watch', // we need to make sure to not call 'deploy' with 'watch' again, // as that would lead to a cycle diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 55f1819e4..e8cea579c 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -118,7 +118,7 @@ export async function makeConfig(): Promise { 'all': { type: 'boolean', desc: 'Deploy all available stacks', default: false }, 'build-exclude': { type: 'array', alias: 'E', desc: 'Do not rebuild asset with the given ID. Can be specified multiple times', default: [] }, 'exclusively': { type: 'boolean', alias: 'e', desc: 'Only deploy requested stacks, don\'t include dependencies' }, - 'require-approval': { type: 'string', choices: [RequireApproval.Never, RequireApproval.AnyChange, RequireApproval.Broadening], desc: 'What security-sensitive changes need manual approval' }, + 'require-approval': { type: 'string', choices: [RequireApproval.NEVER, RequireApproval.ANY_CHANGE, RequireApproval.BROADENING], desc: 'What security-sensitive changes need manual approval' }, 'notification-arns': { type: 'array', desc: 'ARNs of SNS topics that CloudFormation will notify with stack related events. These will be added to ARNs specified with the \'notificationArns\' stack property.' }, // @deprecated(v2) -- tags are part of the Cloud Assembly and tags specified here will be overwritten on the next deployment 'tags': { type: 'array', alias: 't', desc: 'Tags to add to the stack (KEY=VALUE), overrides tags from Cloud Assembly (deprecated)' }, diff --git a/packages/aws-cdk/lib/commands/diff.ts b/packages/aws-cdk/lib/commands/diff.ts index a3453e573..a87a6d03b 100644 --- a/packages/aws-cdk/lib/commands/diff.ts +++ b/packages/aws-cdk/lib/commands/diff.ts @@ -1,294 +1 @@ -import { Writable } from 'stream'; -import { format } from 'util'; -import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { - type DescribeChangeSetOutput, - type TemplateDiff, - formatDifferences, - formatSecurityChanges, - fullDiff, - mangleLikeCloudFormation, -} from '@aws-cdk/cloudformation-diff'; -import type * as cxapi from '@aws-cdk/cx-api'; -import * as chalk from 'chalk'; -import { ToolkitError } from '../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -import type { NestedStackTemplates } from '../api/cloudformation'; -import { info, warning } from '../logging'; - -/* - * Custom writable stream that collects text into a string buffer. - * Used on classes that take in and directly write to a stream, but - * we intend to capture the output rather than print. - */ -class StringWriteStream extends Writable { - private buffer: string[] = []; - - constructor() { - super(); - } - - _write(chunk: any, _encoding: string, callback: (error?: Error | null) => void): void { - this.buffer.push(chunk.toString()); - callback(); - } - - toString(): string { - return this.buffer.join(''); - } -} - -/** - * Output of formatStackDiff - */ -export interface FormatStackDiffOutput { - /** - * Number of stacks with diff changes - */ - readonly numStacksWithChanges: number; - - /** - * Complete formatted diff - */ - readonly formattedDiff: string; -} - -/** - * Formats the differences between two template states and returns it as a string. - * - * @param oldTemplate the old/current state of the stack. - * @param newTemplate the new/target state of the stack. - * @param strict do not filter out AWS::CDK::Metadata or Rules - * @param context lines of context to use in arbitrary JSON diff - * @param quiet silences \'There were no differences\' messages - * - * @returns the formatted diff, and the number of stacks in this stack tree that have differences, including the top-level root stack - */ -export function formatStackDiff( - oldTemplate: any, - newTemplate: cxapi.CloudFormationStackArtifact, - strict: boolean, - context: number, - quiet: boolean, - stackName?: string, - changeSet?: DescribeChangeSetOutput, - isImport?: boolean, - nestedStackTemplates?: { [nestedStackLogicalId: string]: NestedStackTemplates }): FormatStackDiffOutput { - let diff = fullDiff(oldTemplate, newTemplate.template, changeSet, isImport); - - // The stack diff is formatted via `Formatter`, which takes in a stream - // and sends its output directly to that stream. To faciliate use of the - // global CliIoHost, we create our own stream to capture the output of - // `Formatter` and return the output as a string for the consumer of - // `formatStackDiff` to decide what to do with it. - const stream = new StringWriteStream(); - - let numStacksWithChanges = 0; - let formattedDiff = ''; - let filteredChangesCount = 0; - try { - // must output the stack name if there are differences, even if quiet - if (stackName && (!quiet || !diff.isEmpty)) { - stream.write(format('Stack %s\n', chalk.bold(stackName))); - } - - if (!quiet && isImport) { - stream.write('Parameters and rules created during migration do not affect resource configuration.\n'); - } - - // detect and filter out mangled characters from the diff - if (diff.differenceCount && !strict) { - const mangledNewTemplate = JSON.parse(mangleLikeCloudFormation(JSON.stringify(newTemplate.template))); - const mangledDiff = fullDiff(oldTemplate, mangledNewTemplate, changeSet); - filteredChangesCount = Math.max(0, diff.differenceCount - mangledDiff.differenceCount); - if (filteredChangesCount > 0) { - diff = mangledDiff; - } - } - - // filter out 'AWS::CDK::Metadata' resources from the template - // filter out 'CheckBootstrapVersion' rules from the template - if (!strict) { - obscureDiff(diff); - } - - if (!diff.isEmpty) { - numStacksWithChanges++; - - // formatDifferences updates the stream with the formatted stack diff - formatDifferences(stream, diff, { - ...logicalIdMapFromTemplate(oldTemplate), - ...buildLogicalToPathMap(newTemplate), - }, context); - - // store the stream containing a formatted stack diff - formattedDiff = stream.toString(); - } else if (!quiet) { - info(chalk.green('There were no differences')); - } - } finally { - stream.end(); - } - - if (filteredChangesCount > 0) { - info(chalk.yellow(`Omitted ${filteredChangesCount} changes because they are likely mangled non-ASCII characters. Use --strict to print them.`)); - } - - for (const nestedStackLogicalId of Object.keys(nestedStackTemplates ?? {})) { - if (!nestedStackTemplates) { - break; - } - const nestedStack = nestedStackTemplates[nestedStackLogicalId]; - - (newTemplate as any)._template = nestedStack.generatedTemplate; - const nextDiff = formatStackDiff( - nestedStack.deployedTemplate, - newTemplate, - strict, - context, - quiet, - nestedStack.physicalName ?? nestedStackLogicalId, - undefined, - isImport, - nestedStack.nestedStackTemplates, - ); - numStacksWithChanges += nextDiff.numStacksWithChanges; - formattedDiff += nextDiff.formattedDiff; - } - - return { - numStacksWithChanges, - formattedDiff, - }; -} - -export enum RequireApproval { - Never = 'never', - - AnyChange = 'any-change', - - Broadening = 'broadening', -} - -/** - * Output of formatSecurityDiff - */ -export interface FormatSecurityDiffOutput { - /** - * Complete formatted security diff, if it is prompt-worthy - */ - readonly formattedDiff?: string; -} - -/** - * Formats the security changes of this diff, if the change is impactful enough according to the approval level - * - * Returns the diff if the changes are prompt-worthy, an empty object otherwise. - */ -export function formatSecurityDiff( - oldTemplate: any, - newTemplate: cxapi.CloudFormationStackArtifact, - requireApproval: RequireApproval, - stackName?: string, - changeSet?: DescribeChangeSetOutput, -): FormatSecurityDiffOutput { - const diff = fullDiff(oldTemplate, newTemplate.template, changeSet); - - if (diffRequiresApproval(diff, requireApproval)) { - info(format('Stack %s\n', chalk.bold(stackName))); - - // eslint-disable-next-line max-len - 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'); - - // The security diff is formatted via `Formatter`, which takes in a stream - // and sends its output directly to that stream. To faciliate use of the - // global CliIoHost, we create our own stream to capture the output of - // `Formatter` and return the output as a string for the consumer of - // `formatSecurityDiff` to decide what to do with it. - const stream = new StringWriteStream(); - try { - // formatSecurityChanges updates the stream with the formatted security diff - formatSecurityChanges(stream, diff, buildLogicalToPathMap(newTemplate)); - } finally { - stream.end(); - } - // store the stream containing a formatted stack diff - const formattedDiff = stream.toString(); - return { formattedDiff }; - } - return {}; -} - -/** - * 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 diffRequiresApproval(diff: 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 ToolkitError(`Unrecognized approval level: ${requireApproval}`); - } -} - -function buildLogicalToPathMap(stack: cxapi.CloudFormationStackArtifact) { - const map: { [id: string]: string } = {}; - for (const md of stack.findMetadataByType(cxschema.ArtifactMetadataEntryType.LOGICAL_ID)) { - map[md.data as string] = md.path; - } - return map; -} - -function logicalIdMapFromTemplate(template: any) { - const ret: Record = {}; - - for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) { - const path = (resource as any)?.Metadata?.['aws:cdk:path']; - if (path) { - ret[logicalId] = path; - } - } - return ret; -} - -/** - * Remove any template elements that we don't want to show users. - * This is currently: - * - AWS::CDK::Metadata resource - * - CheckBootstrapVersion Rule - */ -function obscureDiff(diff: TemplateDiff) { - if (diff.unknown) { - // see https://github.com/aws/aws-cdk/issues/17942 - diff.unknown = diff.unknown.filter(change => { - if (!change) { - return true; - } - if (change.newValue?.CheckBootstrapVersion) { - return false; - } - if (change.oldValue?.CheckBootstrapVersion) { - return false; - } - return true; - }); - } - - if (diff.resources) { - diff.resources = diff.resources.filter(change => { - if (!change) { - return true; - } - if (change.newResourceType === 'AWS::CDK::Metadata') { - return false; - } - if (change.oldResourceType === 'AWS::CDK::Metadata') { - return false; - } - return true; - }); - } -} +export { formatStackDiff, formatSecurityDiff, RequireApproval } from '../../../@aws-cdk/tmp-toolkit-helpers/src/api'; diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index 8a15c69ad..b4d96d971 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -216,7 +216,7 @@ describe('deploy', () => { // WHEN await cdkToolkit.deploy({ selector: { patterns: ['Test-Stack-A-Display-Name'] }, - requireApproval: RequireApproval.Never, + requireApproval: RequireApproval.NEVER, hotswap: HotswapMode.FALL_BACK, }); @@ -1627,7 +1627,7 @@ describe('rollback', () => { selector: { patterns: [] }, hotswap: HotswapMode.FULL_DEPLOYMENT, rollback: false, - requireApproval: RequireApproval.Never, + requireApproval: RequireApproval.NEVER, force: useForce, }); diff --git a/yarn.lock b/yarn.lock index 9aacc7834..3d4226f35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4619,6 +4619,14 @@ chai@^5.2.0: loupe "^3.1.0" pathval "^2.0.0" +chalk@4, chalk@^4, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -4628,14 +4636,6 @@ chalk@^2.4.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"