Skip to content

Commit 98ecc74

Browse files
committed
feat(aws-cdk): add resource change filtering for deploy and diff commands
Add --allow-resource-changes option to restrict deployments to specific resource types or properties. This enables safer deployments by preventing unintended changes to critical resources. - Add ResourceFilter API with pattern matching for resource types and properties - Integrate validation into deploy and diff commands - Support wildcard patterns (e.g., AWS::Lambda::*) - Provide detailed violation messages with remediation guidance - Include comprehensive unit tests and integration tests
1 parent 3d7b09b commit 98ecc74

File tree

7 files changed

+423
-0
lines changed

7 files changed

+423
-0
lines changed

packages/aws-cdk/lib/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './deployments';
66
export * from './aws-auth';
77
export * from './cloud-assembly';
88
export * from './notices';
9+
export * from './resource-filter';
910

1011
export * from '../../../@aws-cdk/toolkit-lib/lib/api/diff';
1112
export * from '../../../@aws-cdk/toolkit-lib/lib/api/io';
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { ResourceDifference } from '@aws-cdk/cloudformation-diff';
2+
import { ToolkitError } from '@aws-cdk/toolkit-lib';
3+
4+
/**
5+
* Represents a resource filter pattern
6+
*/
7+
export interface ResourceFilter {
8+
/**
9+
* The resource type pattern (e.g., 'AWS::Lambda::Function')
10+
*/
11+
resourceType: string;
12+
13+
/**
14+
* Optional property path (e.g., 'Properties.Code.S3Key')
15+
*/
16+
propertyPath?: string;
17+
}
18+
19+
/**
20+
* Parses a filter string into a ResourceFilter object
21+
*/
22+
export function parseResourceFilter(filter: string): ResourceFilter {
23+
const parts = filter.split('.');
24+
const resourceType = parts[0];
25+
26+
if (!resourceType) {
27+
throw new ToolkitError(`Invalid resource filter: '${filter}'. Must specify at least a resource type.`);
28+
}
29+
30+
const propertyPath = parts.length > 1 ? parts.slice(1).join('.') : undefined;
31+
32+
return {
33+
resourceType,
34+
propertyPath,
35+
};
36+
}
37+
38+
/**
39+
* Checks if a resource type matches a filter pattern
40+
*/
41+
export function matchesResourceType(resourceType: string, pattern: string): boolean {
42+
if (pattern === '*') {
43+
return true;
44+
}
45+
46+
if (pattern.endsWith('*')) {
47+
const prefix = pattern.slice(0, -1);
48+
return resourceType.startsWith(prefix);
49+
}
50+
51+
return resourceType === pattern;
52+
}
53+
54+
/**
55+
* Checks if a property change matches a filter
56+
*/
57+
export function matchesPropertyFilter(
58+
resourceType: string,
59+
propertyName: string,
60+
filter: ResourceFilter,
61+
): boolean {
62+
// First check if resource type matches
63+
if (!matchesResourceType(resourceType, filter.resourceType)) {
64+
return false;
65+
}
66+
67+
// If no property path specified in filter, any property change is allowed
68+
if (!filter.propertyPath) {
69+
return true;
70+
}
71+
72+
// Check if the property path matches
73+
const filterPath = filter.propertyPath.startsWith('Properties.')
74+
? filter.propertyPath.slice('Properties.'.length)
75+
: filter.propertyPath;
76+
77+
return propertyName === filterPath || propertyName.startsWith(filterPath + '.');
78+
}
79+
80+
/**
81+
* Validates resource changes against allowed filters
82+
*/
83+
export function validateResourceChanges(
84+
resourceChanges: { [logicalId: string]: ResourceDifference },
85+
allowedFilters: string[],
86+
): { isValid: boolean; violations: string[] } {
87+
if (allowedFilters.length === 0) {
88+
return { isValid: true, violations: [] };
89+
}
90+
91+
const filters = allowedFilters.map(parseResourceFilter);
92+
const violations: string[] = [];
93+
94+
for (const [logicalId, change] of Object.entries(resourceChanges)) {
95+
const resourceType = change.resourceType;
96+
97+
if (!resourceType) {
98+
continue;
99+
}
100+
101+
// Check if the resource type change itself is allowed
102+
let resourceTypeAllowed = false;
103+
for (const filter of filters) {
104+
if (matchesResourceType(resourceType, filter.resourceType) && !filter.propertyPath) {
105+
resourceTypeAllowed = true;
106+
break;
107+
}
108+
}
109+
110+
// If it's a resource addition/removal, check resource type level permission
111+
if (change.isAddition || change.isRemoval) {
112+
if (!resourceTypeAllowed) {
113+
const action = change.isAddition ? 'addition' : 'removal';
114+
violations.push(`${logicalId} (${resourceType}): ${action} not allowed by filters`);
115+
}
116+
continue;
117+
}
118+
119+
// For updates, check each property change
120+
const propertyUpdates = change.propertyUpdates;
121+
for (const [propertyName] of Object.entries(propertyUpdates)) {
122+
let propertyAllowed = false;
123+
124+
for (const filter of filters) {
125+
if (matchesPropertyFilter(resourceType, propertyName, filter)) {
126+
propertyAllowed = true;
127+
break;
128+
}
129+
}
130+
131+
if (!propertyAllowed) {
132+
violations.push(`${logicalId} (${resourceType}): property '${propertyName}' change not allowed by filters`);
133+
}
134+
}
135+
136+
// Check other changes (non-property changes)
137+
const otherChanges = change.otherChanges;
138+
if (Object.keys(otherChanges).length > 0 && !resourceTypeAllowed) {
139+
violations.push(`${logicalId} (${resourceType}): non-property changes not allowed by filters`);
140+
}
141+
}
142+
143+
return {
144+
isValid: violations.length === 0,
145+
violations,
146+
};
147+
}
148+
149+
/**
150+
* Formats violation messages for display to the user
151+
*/
152+
export function formatViolationMessage(
153+
violations: string[],
154+
allowedFilters: string[],
155+
): string {
156+
const lines = [
157+
'❌ Deployment aborted: Detected changes to resources outside allowed filters',
158+
'',
159+
'Allowed resource changes:',
160+
...allowedFilters.map(filter => ` • ${filter}`),
161+
'',
162+
'Detected changes that violate the filter:',
163+
...violations.map(violation => ` • ${violation}`),
164+
'',
165+
'To proceed with these changes, either:',
166+
' 1. Review and remove the unwanted changes from your CDK code',
167+
' 2. Update your --allow-resource-changes filters to include these resource types',
168+
' 3. Remove the --allow-resource-changes option to deploy all changes',
169+
];
170+
171+
return lines.join('\n');
172+
}

packages/aws-cdk/lib/cli/cdk-toolkit.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { Bootstrapper } from '../api/bootstrap';
3232
import { ExtendedStackSelection, StackCollection } from '../api/cloud-assembly';
3333
import type { Deployments, SuccessfulDeployStackResult } from '../api/deployments';
3434
import { mappingsByEnvironment, parseMappingGroups } from '../api/refactor';
35+
import { validateResourceChanges, formatViolationMessage } from '../api/resource-filter';
3536
import { type Tag } from '../api/tags';
3637
import { StackActivityProgress } from '../commands/deploy';
3738
import { listStacks } from '../commands/list-stacks';
@@ -272,6 +273,17 @@ export class CdkToolkit {
272273
contextLines,
273274
quiet,
274275
});
276+
277+
// Validate resource changes against allowed filters
278+
if (options.allowResourceChanges && options.allowResourceChanges.length > 0) {
279+
const validation = validateResourceChanges(formatter.diffs.resources.changes, options.allowResourceChanges);
280+
if (!validation.isValid) {
281+
const violationMessage = formatViolationMessage(validation.violations, options.allowResourceChanges);
282+
await this.ioHost.asIoHelper().defaults.error(violationMessage);
283+
return 1;
284+
}
285+
}
286+
275287
diffs = diff.numStacksWithChanges;
276288
await this.ioHost.asIoHelper().defaults.info(diff.formattedDiff);
277289
}
@@ -365,6 +377,17 @@ export class CdkToolkit {
365377
contextLines,
366378
quiet,
367379
});
380+
381+
// Validate resource changes against allowed filters
382+
if (options.allowResourceChanges && options.allowResourceChanges.length > 0) {
383+
const validation = validateResourceChanges(formatter.diffs.resources.changes, options.allowResourceChanges);
384+
if (!validation.isValid) {
385+
const violationMessage = formatViolationMessage(validation.violations, options.allowResourceChanges);
386+
await this.ioHost.asIoHelper().defaults.error(violationMessage);
387+
return 1;
388+
}
389+
}
390+
368391
await this.ioHost.asIoHelper().defaults.info(diff.formattedDiff);
369392
diffs += diff.numStacksWithChanges;
370393
}
@@ -478,6 +501,23 @@ export class CdkToolkit {
478501
return;
479502
}
480503

504+
// Always validate resource changes if filters are specified, regardless of approval settings
505+
if (options.allowResourceChanges && options.allowResourceChanges.length > 0) {
506+
const currentTemplate = await this.props.deployments.readCurrentTemplate(stack);
507+
const formatter = new DiffFormatter({
508+
templateInfo: {
509+
oldTemplate: currentTemplate,
510+
newTemplate: stack,
511+
},
512+
});
513+
514+
const validation = validateResourceChanges(formatter.diffs.resources.changes, options.allowResourceChanges);
515+
if (!validation.isValid) {
516+
const violationMessage = formatViolationMessage(validation.violations, options.allowResourceChanges);
517+
throw new ToolkitError(violationMessage);
518+
}
519+
}
520+
481521
if (requireApproval !== RequireApproval.NEVER) {
482522
const currentTemplate = await this.props.deployments.readCurrentTemplate(stack);
483523
const formatter = new DiffFormatter({
@@ -486,6 +526,7 @@ export class CdkToolkit {
486526
newTemplate: stack,
487527
},
488528
});
529+
489530
const securityDiff = formatter.formatSecurityDiff();
490531
if (requiresApproval(requireApproval, securityDiff.permissionChangeType)) {
491532
const motivation = '"--require-approval" is enabled and stack includes security-sensitive updates';
@@ -1580,6 +1621,13 @@ export interface DiffOptions {
15801621
* @default false
15811622
*/
15821623
readonly includeMoves?: boolean;
1624+
1625+
/**
1626+
* Allow only changes to specified resource types or properties
1627+
*
1628+
* @default []
1629+
*/
1630+
readonly allowResourceChanges?: string[];
15831631
}
15841632

15851633
interface CfnDeployOptions {
@@ -1783,6 +1831,13 @@ export interface DeployOptions extends CfnDeployOptions, WatchOptions {
17831831
* @default false
17841832
*/
17851833
readonly ignoreNoStacks?: boolean;
1834+
1835+
/**
1836+
* Allow only changes to specified resource types or properties
1837+
*
1838+
* @default []
1839+
*/
1840+
readonly allowResourceChanges?: string[];
17861841
}
17871842

17881843
export interface RollbackOptions {

packages/aws-cdk/lib/cli/cli-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ export async function makeConfig(): Promise<CliConfig> {
209209
'asset-parallelism': { type: 'boolean', desc: 'Whether to build/publish assets in parallel' },
210210
'asset-prebuild': { type: 'boolean', desc: 'Whether to build all assets before deploying the first stack (useful for failing Docker builds)', default: true },
211211
'ignore-no-stacks': { type: 'boolean', desc: 'Whether to deploy if the app contains no stacks', default: false },
212+
'allow-resource-changes': { type: 'array', desc: 'Allow only changes to specified resource types or properties (e.g., AWS::Lambda::Function, AWS::Lambda::Function.Code.S3Key)', default: [] },
212213
},
213214
arg: {
214215
name: 'STACKS',
@@ -360,6 +361,7 @@ export async function makeConfig(): Promise<CliConfig> {
360361
'change-set': { type: 'boolean', alias: 'changeset', desc: 'Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.', default: true },
361362
'import-existing-resources': { type: 'boolean', desc: 'Whether or not the change set imports resources that already exist', default: false },
362363
'include-moves': { type: 'boolean', desc: 'Whether to include moves in the diff', default: false },
364+
'allow-resource-changes': { type: 'array', desc: 'Allow only changes to specified resource types or properties (e.g., AWS::Lambda::Function, AWS::Lambda::Function.Code.S3Key)', default: [] },
363365
},
364366
},
365367
'drift': {

packages/aws-cdk/lib/cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
312312
toolkitStackName: toolkitStackName,
313313
importExistingResources: args.importExistingResources,
314314
includeMoves: args['include-moves'],
315+
allowResourceChanges: args.allowResourceChanges,
315316
});
316317

317318
case 'drift':
@@ -410,6 +411,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
410411
? AssetBuildTime.ALL_BEFORE_DEPLOY
411412
: AssetBuildTime.JUST_IN_TIME,
412413
ignoreNoStacks: args.ignoreNoStacks,
414+
allowResourceChanges: args.allowResourceChanges,
413415
});
414416

415417
case 'rollback':

0 commit comments

Comments
 (0)