Skip to content

Commit 70c374f

Browse files
authored
fix(cloudformation-diff): fails on CloudFormation intrinsics in unexpected places (#26791)
The `cloudformation-diff` module was written to parse templates that CDK itself would produce, mostly consisting of concrete values and barely any CloudFormation intrinsics. It would crash when encountering CloudFormation intrinsics in unexpected places (for example, an intrinsic where it expected an array). Make the parsing more robust, checking the types of various values before we try and access it. Property-based tests generate random templates to make sure we didn't forget any edge cases. Upgrade `fast-check` to the latest version while we're at it. Fixes #7413. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 01a7b5b commit 70c374f

File tree

19 files changed

+431
-158
lines changed

19 files changed

+431
-158
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* A value that may or may not be parseable
3+
*/
4+
export type MaybeParsed<A> = Parsed<A> | UnparseableCfn;
5+
6+
export interface Parsed<A> {
7+
readonly type: 'parsed';
8+
readonly value: A;
9+
}
10+
11+
export interface UnparseableCfn {
12+
readonly type: 'unparseable';
13+
readonly repr: string;
14+
}
15+
16+
export function mkParsed<A>(value: A): Parsed<A> {
17+
return { type: 'parsed', value };
18+
}
19+
20+
export function mkUnparseable(value: any): UnparseableCfn {
21+
return {
22+
type: 'unparseable',
23+
repr: typeof value === 'string' ? value : JSON.stringify(value),
24+
};
25+
}

packages/@aws-cdk/cloudformation-diff/lib/diff/util.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ export function diffKeyedEntities<T>(
113113
for (const logicalId of unionOf(Object.keys(oldValue || {}), Object.keys(newValue || {}))) {
114114
const oldElement = oldValue && oldValue[logicalId];
115115
const newElement = newValue && newValue[logicalId];
116+
117+
if (oldElement === undefined && newElement === undefined) {
118+
// Shouldn't happen in reality, but may happen in tests. Skip.
119+
continue;
120+
}
121+
116122
result[logicalId] = elementDiff(oldElement, newElement, logicalId);
117123
}
118124
return result;

packages/@aws-cdk/cloudformation-diff/lib/iam/iam-changes.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as cfnspec from '@aws-cdk/cfnspec';
22
import * as chalk from 'chalk';
33
import { ManagedPolicyAttachment, ManagedPolicyJson } from './managed-policy';
44
import { parseLambdaPermission, parseStatements, Statement, StatementJson } from './statement';
5+
import { MaybeParsed } from '../diff/maybe-parsed';
56
import { PropertyChange, PropertyMap, ResourceChange } from '../diff/types';
67
import { DiffableCollection } from '../diffable';
78
import { renderIntrinsics } from '../render-intrinsics';
@@ -184,7 +185,7 @@ export class IamChanges {
184185
* Parse a list of policies on an identity
185186
*/
186187
private readIdentityPolicies(policies: any, logicalId: string): Statement[] {
187-
if (policies === undefined) { return []; }
188+
if (policies === undefined || !Array.isArray(policies)) { return []; }
188189

189190
const appliesToPrincipal = 'AWS:${' + logicalId + '}';
190191

@@ -276,8 +277,8 @@ function defaultResource(resource: string, statements: Statement[]) {
276277
}
277278

278279
export interface IamChangesJson {
279-
statementAdditions?: StatementJson[];
280-
statementRemovals?: StatementJson[];
281-
managedPolicyAdditions?: ManagedPolicyJson[];
282-
managedPolicyRemovals?: ManagedPolicyJson[];
280+
statementAdditions?: Array<MaybeParsed<StatementJson>>;
281+
statementRemovals?: Array<MaybeParsed<StatementJson>>;
282+
managedPolicyAdditions?: Array<MaybeParsed<ManagedPolicyJson>>;
283+
managedPolicyRemovals?: Array<MaybeParsed<ManagedPolicyJson>>;
283284
}

packages/@aws-cdk/cloudformation-diff/lib/iam/managed-policy.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { MaybeParsed, mkParsed } from '../diff/maybe-parsed';
2+
13
export class ManagedPolicyAttachment {
24
public static parseManagedPolicies(identityArn: string, arns: string | string[]): ManagedPolicyAttachment[] {
35
return typeof arns === 'string'
@@ -19,8 +21,11 @@ export class ManagedPolicyAttachment {
1921
*
2022
* @internal
2123
*/
22-
public _toJson(): ManagedPolicyJson {
23-
return { identityArn: this.identityArn, managedPolicyArn: this.managedPolicyArn };
24+
public _toJson(): MaybeParsed<ManagedPolicyJson> {
25+
return mkParsed({
26+
identityArn: this.identityArn,
27+
managedPolicyArn: this.managedPolicyArn,
28+
});
2429
}
2530
}
2631

packages/@aws-cdk/cloudformation-diff/lib/iam/statement.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MaybeParsed, mkParsed, mkUnparseable } from '../diff/maybe-parsed';
12
import { deepRemoveUndefined } from '../util';
23

34
// namespace object imports won't work in the bundle for function exports
@@ -94,17 +95,17 @@ export class Statement {
9495
*
9596
* @internal
9697
*/
97-
public _toJson(): StatementJson {
98+
public _toJson(): MaybeParsed<StatementJson> {
9899
return this.serializedIntrinsic
99-
? this.serializedIntrinsic
100-
: deepRemoveUndefined({
100+
? mkUnparseable(this.serializedIntrinsic)
101+
: mkParsed(deepRemoveUndefined({
101102
sid: this.sid,
102103
effect: this.effect,
103104
resources: this.resources._toJson(),
104105
principals: this.principals._toJson(),
105106
actions: this.actions._toJson(),
106107
condition: this.condition,
107-
});
108+
}));
108109
}
109110

110111
/**

packages/@aws-cdk/cloudformation-diff/lib/network/security-group-changes.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,16 @@ export class SecurityGroupChanges {
9797
}
9898

9999
private readInlineRules(rules: any, logicalId: string): SecurityGroupRule[] {
100-
if (!rules) { return []; }
100+
if (!rules || !Array.isArray(rules)) { return []; }
101101

102102
// UnCloudFormation so the parser works in an easier domain
103103

104104
const ref = '${' + logicalId + '.GroupId}';
105-
return rules.map((r: any) => new SecurityGroupRule(renderIntrinsics(r), ref));
105+
return rules.flatMap((r: any) => {
106+
const rendered = renderIntrinsics(r);
107+
// SecurityGroupRule is not robust against unparsed objects
108+
return typeof rendered === 'object' ? [new SecurityGroupRule(rendered, ref)] : [];
109+
});
106110
}
107111

108112
private readRuleResource(resource: any): SecurityGroupRule[] {

packages/@aws-cdk/cloudformation-diff/lib/network/security-group-rule.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,12 @@ function peerEqual(a?: RulePeer, b?: RulePeer) {
121121

122122
function findFirst<T>(obj: any, keys: string[], fn: (x: string) => T): T | undefined {
123123
for (const key of keys) {
124-
if (key in obj) {
125-
return fn(obj[key]);
124+
try {
125+
if (key in obj) {
126+
return fn(obj[key]);
127+
}
128+
} catch (e) {
129+
debugger;
126130
}
127131
}
128132
return undefined;

packages/@aws-cdk/cloudformation-diff/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"@aws-cdk/pkglint": "0.0.0",
3636
"@types/jest": "^29.5.3",
3737
"@types/string-width": "^4.0.1",
38-
"fast-check": "^2.25.0",
38+
"fast-check": "^3.12.0",
3939
"jest": "^29.6.2",
4040
"ts-jest": "^29.1.1"
4141
},

packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as fc from 'fast-check';
2+
import { arbitraryTemplate } from './test-arbitraries';
13
import { diffTemplate, ResourceImpact } from '../lib/diff-template';
24

35
const POLICY_DOCUMENT = { foo: 'Bar' }; // Obviously a fake one!
@@ -689,3 +691,13 @@ test('handles a resource changing its Type', () => {
689691
resourceTypes: { newType: 'AWS::ApiGateway::RestApi', oldType: 'AWS::Serverless::Api' },
690692
});
691693
});
694+
695+
test('diffing any two arbitrary templates should not crash', () => {
696+
// We're not interested in making sure we find the right differences here -- just
697+
// that we're not crashing.
698+
fc.assert(fc.property(arbitraryTemplate, arbitraryTemplate, (t1, t2) => {
699+
diffTemplate(t1, t2);
700+
}), {
701+
// path: '1:0:0:0:3:0:1:1:1:1:1:1:1:1:1:1:1:1:1:2:1:1:1',
702+
});
703+
});

packages/@aws-cdk/cloudformation-diff/test/iam/detect-changes.test.ts

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { diffTemplate } from '../../lib';
2+
import { MaybeParsed } from '../../lib/diff/maybe-parsed';
3+
import { IamChangesJson } from '../../lib/iam/iam-changes';
4+
import { deepRemoveUndefined } from '../../lib/util';
25
import { poldoc, policy, resource, role, template } from '../util';
36

47
test('shows new AssumeRolePolicyDocument', () => {
@@ -14,7 +17,7 @@ test('shows new AssumeRolePolicyDocument', () => {
1417
}));
1518

1619
// THEN
17-
expect(diff.iamChanges._toJson()).toEqual({
20+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
1821
statementAdditions: [
1922
{
2023
effect: 'Allow',
@@ -41,7 +44,7 @@ test('implicitly knows principal of identity policy for all resource types', ()
4144
}));
4245

4346
// THEN
44-
expect(diff.iamChanges._toJson()).toEqual({
47+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
4548
statementAdditions: [
4649
{
4750
effect: 'Allow',
@@ -73,7 +76,7 @@ test('policies on an identity object', () => {
7376
}));
7477

7578
// THEN
76-
expect(diff.iamChanges._toJson()).toEqual({
79+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
7780
statementAdditions: [
7881
{
7982
effect: 'Allow',
@@ -86,6 +89,39 @@ test('policies on an identity object', () => {
8689
}
8790
});
8891

92+
test('statement is an intrinsic', () => {
93+
const diff = diffTemplate({}, template({
94+
MyIdentity: resource('AWS::IAM::User', {
95+
Policies: [
96+
{
97+
PolicyName: 'Polly',
98+
PolicyDocument: poldoc({
99+
'Fn::If': [
100+
'SomeCondition',
101+
{
102+
Effect: 'Allow',
103+
Action: 's3:DoThatThing',
104+
Resource: '*',
105+
},
106+
{ Ref: 'AWS::NoValue' },
107+
],
108+
}),
109+
},
110+
],
111+
}),
112+
}));
113+
114+
// THEN
115+
expect(diff.iamChanges._toJson()).toEqual({
116+
statementAdditions: [
117+
{
118+
type: 'unparseable',
119+
repr: '{"Fn::If":["SomeCondition",{"Effect":"Allow","Action":"s3:DoThatThing","Resource":"*"}]}',
120+
},
121+
],
122+
});
123+
});
124+
89125
test('if policy is attached to multiple roles all are shown', () => {
90126
// WHEN
91127
const diff = diffTemplate({}, template({
@@ -100,7 +136,7 @@ test('if policy is attached to multiple roles all are shown', () => {
100136
}));
101137

102138
// THEN
103-
expect(diff.iamChanges._toJson()).toEqual({
139+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
104140
statementAdditions: [
105141
{
106142
effect: 'Allow',
@@ -131,7 +167,7 @@ test('correctly parses Lambda permissions', () => {
131167
}));
132168

133169
// THEN
134-
expect(diff.iamChanges._toJson()).toEqual({
170+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
135171
statementAdditions: [
136172
{
137173
effect: 'Allow',
@@ -162,7 +198,7 @@ test('implicitly knows resource of (queue) resource policy even if * given', ()
162198
}));
163199

164200
// THEN
165-
expect(diff.iamChanges._toJson()).toEqual({
201+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
166202
statementAdditions: [
167203
{
168204
effect: 'Allow',
@@ -189,7 +225,7 @@ test('finds sole statement removals', () => {
189225
}), {});
190226

191227
// THEN
192-
expect(diff.iamChanges._toJson()).toEqual({
228+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
193229
statementRemovals: [
194230
{
195231
effect: 'Allow',
@@ -233,7 +269,7 @@ test('finds one of many statement removals', () => {
233269
}));
234270

235271
// THEN
236-
expect(diff.iamChanges._toJson()).toEqual({
272+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
237273
statementRemovals: [
238274
{
239275
effect: 'Allow',
@@ -254,7 +290,7 @@ test('finds policy attachments', () => {
254290
}));
255291

256292
// THEN
257-
expect(diff.iamChanges._toJson()).toEqual({
293+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
258294
managedPolicyAdditions: [
259295
{
260296
identityArn: '${SomeRole}',
@@ -279,7 +315,7 @@ test('finds policy removals', () => {
279315
}));
280316

281317
// THEN
282-
expect(diff.iamChanges._toJson()).toEqual({
318+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
283319
managedPolicyRemovals: [
284320
{
285321
identityArn: '${SomeRole}',
@@ -314,7 +350,7 @@ test('queuepolicy queue change counts as removal+addition', () => {
314350
}));
315351

316352
// THEN
317-
expect(diff.iamChanges._toJson()).toEqual({
353+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
318354
statementAdditions: [
319355
{
320356
effect: 'Allow',
@@ -354,7 +390,7 @@ test('supports Fn::If in the top-level property value of Role', () => {
354390
}));
355391

356392
// THEN
357-
expect(diff.iamChanges._toJson()).toEqual({
393+
expect(unwrapParsed(diff.iamChanges._toJson())).toEqual({
358394
managedPolicyAdditions: [
359395
{
360396
identityArn: '${MyRole}',
@@ -420,3 +456,22 @@ test('supports Fn::If in the elements of an array-typed property of Role', () =>
420456
expect(changedPolicies[resourceColumn]).toContain('{"Fn::If":["SomeCondition",{"PolicyName":"S3","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}}]}');
421457
expect(changedPolicies[principalColumn]).toContain('AWS:${MyRole}');
422458
});
459+
460+
/**
461+
* Assume that all types are parsed, and unwrap them
462+
*/
463+
function unwrapParsed(chg: IamChangesJson) {
464+
return deepRemoveUndefined({
465+
managedPolicyAdditions: chg.managedPolicyAdditions?.map(unwrap1),
466+
managedPolicyRemovals: chg.managedPolicyRemovals?.map(unwrap1),
467+
statementAdditions: chg.statementAdditions?.map(unwrap1),
468+
statementRemovals: chg.statementRemovals?.map(unwrap1),
469+
});
470+
471+
function unwrap1<A>(x: MaybeParsed<A>): A {
472+
if (x.type !== 'parsed') {
473+
throw new Error(`Expected parsed expression, found: "${x.repr}"`);
474+
}
475+
return x.value;
476+
}
477+
}

0 commit comments

Comments
 (0)