Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ interface GarbageCollectorProps {
* @default true
*/
readonly confirm?: boolean;

/**
* Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors during garbage collection.
* You must explicitly specify non-CDK stack names - CDK stacks will be rejected to prevent accidental asset deletion.
*
* @default undefined
*/
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
}

/**
Expand All @@ -191,6 +199,7 @@ export class GarbageCollector {
private bootstrapStackName: string;
private confirm: boolean;
private ioHelper: IoHelper;
private skipUnauthorizedStacksWhenNonCdk?: string[];

public constructor(readonly props: GarbageCollectorProps) {
this.ioHelper = props.ioHelper;
Expand All @@ -201,6 +210,7 @@ export class GarbageCollector {
this.permissionToDelete = ['delete-tagged', 'full'].includes(props.action);
this.permissionToTag = ['tag', 'full'].includes(props.action);
this.confirm = props.confirm ?? true;
this.skipUnauthorizedStacksWhenNonCdk = props.skipUnauthorizedStacksWhenNonCdk;

this.bootstrapStackName = props.bootstrapStackName ?? DEFAULT_TOOLKIT_STACK_NAME;
}
Expand All @@ -224,13 +234,15 @@ export class GarbageCollector {
ioHelper: this.ioHelper,
activeAssets,
qualifier,
skipUnauthorizedStacksWhenNonCdk: this.skipUnauthorizedStacksWhenNonCdk,
});
// Start the background refresh
const backgroundStackRefresh = new BackgroundStackRefresh({
cfn,
ioHelper: this.ioHelper,
activeAssets,
qualifier,
skipUnauthorizedStacksWhenNonCdk: this.skipUnauthorizedStacksWhenNonCdk,
});
backgroundStackRefresh.start();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ParameterDeclaration } from '@aws-sdk/client-cloudformation';
import { minimatch } from 'minimatch';
import { ToolkitError } from '../../toolkit/toolkit-error';
import type { ICloudFormationClient } from '../aws-auth/private';
import type { IoHelper } from '../io/private';
Expand All @@ -20,6 +21,23 @@ export class ActiveAssetCache {
}
}

/**
* Check if a stack name matches any of the skip patterns using glob matching
*/
function shouldSkipStack(stackName: string, skipPatterns?: string[]): boolean {
if (!skipPatterns || skipPatterns.length === 0) {
return false;
}

// Extract stack name from ARN if entire path is passed
// fetchAllStackTemplates can return either stack name or id so we handle both
const extractedStackName = stackName.includes(':cloudformation:') && stackName.includes(':stack/')
? stackName.split('/')[1] || stackName
: stackName;

return skipPatterns.some(pattern => minimatch(extractedStackName, pattern));
}

async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | undefined>) {
let finished = false;
let nextToken: string | undefined;
Expand All @@ -35,8 +53,14 @@ async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | unde
* Fetches all relevant stack templates from CloudFormation. It ignores the following stacks:
* - stacks in DELETE_COMPLETE or DELETE_IN_PROGRESS stage
* - stacks that are using a different bootstrap qualifier
* - unauthorized stacks that match the skip patterns (when specified)
*/
async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHelper, qualifier?: string) {
async function fetchAllStackTemplates(
cfn: ICloudFormationClient,
ioHelper: IoHelper,
qualifier?: string,
skipUnauthorizedStacksWhenNonCdk?: string[],
) {
const stackNames: string[] = [];
await paginateSdkCall(async (nextToken) => {
const stacks = await cfn.listStacks({ NextToken: nextToken });
Expand All @@ -56,20 +80,40 @@ async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHe

const templates: string[] = [];
for (const stack of stackNames) {
let summary;
summary = await cfn.getTemplateSummary({
StackName: stack,
});
try {
let summary;
summary = await cfn.getTemplateSummary({
StackName: stack,
});

if (bootstrapFilter(summary.Parameters, qualifier)) {
// This stack is definitely bootstrapped to a different qualifier so we can safely ignore it
continue;
}

if (bootstrapFilter(summary.Parameters, qualifier)) {
// This stack is definitely bootstrapped to a different qualifier so we can safely ignore it
continue;
} else {
const template = await cfn.getTemplate({
StackName: stack,
});

templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters));
} catch (error: any) {
// Check if this is a CloudFormation access denied error
if (error.name === 'AccessDenied') {
if (shouldSkipStack(stack, skipUnauthorizedStacksWhenNonCdk)) {
await ioHelper.defaults.warn(
`Skipping unauthorized stack '${stack}' as specified in --skip-unauthorized-stacks-when-noncdk`,
);
continue;
}

throw new ToolkitError(
`Access denied when trying to access stack '${stack}'. ` +
'If this is a non-CDK stack that you want to skip, add it to --skip-unauthorized-stacks-when-noncdk.',
);
}

// Re-throw the error if it's not handled
throw error;
}
}

Expand Down Expand Up @@ -102,11 +146,17 @@ export interface RefreshStacksProps {
readonly ioHelper: IoHelper;
readonly activeAssets: ActiveAssetCache;
readonly qualifier?: string;
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
}

export async function refreshStacks(props: RefreshStacksProps) {
try {
const stacks = await fetchAllStackTemplates(props.cfn, props.ioHelper, props.qualifier);
const stacks = await fetchAllStackTemplates(
props.cfn,
props.ioHelper,
props.qualifier,
props.skipUnauthorizedStacksWhenNonCdk,
);
for (const stack of stacks) {
props.activeAssets.rememberStack(stack);
}
Expand Down Expand Up @@ -138,6 +188,11 @@ export interface BackgroundStackRefreshProps {
* Stack bootstrap qualifier
*/
readonly qualifier?: string;

/**
* Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors
*/
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
}

/**
Expand Down Expand Up @@ -166,6 +221,7 @@ export class BackgroundStackRefresh {
ioHelper: this.props.ioHelper,
activeAssets: this.props.activeAssets,
qualifier: this.props.qualifier,
skipUnauthorizedStacksWhenNonCdk: this.props.skipUnauthorizedStacksWhenNonCdk,
});
this.justRefreshedStacks();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,115 @@ describe('CloudFormation API calls', () => {
},
});
});

test('skip stacks using glob patterns when unauthorized', async () => {
mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] });

cfnClient.on(ListStacksCommand).resolves({
StackSummaries: [
{ StackName: 'CDKStack1', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
{ StackName: 'Legacy-App-Stack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
{ StackName: 'Legacy-DB-Stack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
{ StackName: 'ThirdParty-Service', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
],
});

cfnClient.on(GetTemplateSummaryCommand, { StackName: 'CDKStack1' }).resolves({
Parameters: [{ ParameterKey: 'BootstrapVersion', DefaultValue: '/cdk-bootstrap/abcde/version' }],
});
cfnClient.on(GetTemplateCommand, { StackName: 'CDKStack1' }).resolves({ TemplateBody: 'cdk-template' });

const accessDeniedError = new Error('Access Denied');
accessDeniedError.name = 'AccessDenied';

['Legacy-App-Stack', 'Legacy-DB-Stack', 'ThirdParty-Service'].forEach(stackName => {
cfnClient.on(GetTemplateSummaryCommand, { StackName: stackName }).resolves({ Parameters: [] });
cfnClient.on(GetTemplateCommand, { StackName: stackName }).rejects(accessDeniedError);
});

garbageCollector = new GarbageCollector({
sdkProvider: new MockSdkProvider(),
ioHelper: ioHost.asHelper('gc'),
action: 'full',
resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' },
bootstrapStackName: 'GarbageStack',
rollbackBufferDays: 0,
createdBufferDays: 0,
type: 's3',
confirm: false,
skipUnauthorizedStacksWhenNonCdk: ['Legacy-*', 'ThirdParty-*'],
});

await garbageCollector.garbageCollect();

expect(cfnClient).toHaveReceivedCommandWith(GetTemplateCommand, { StackName: 'CDKStack1' });
expect(ioHost.notifySpy).toHaveBeenCalledWith(
expect.objectContaining({ level: 'warn', message: expect.stringContaining("Skipping unauthorized stack 'Legacy-App-Stack'") }),
);
});

test('fail on unauthorized stack not matching skip patterns', async () => {
mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] });

cfnClient.on(ListStacksCommand).resolves({
StackSummaries: [{ StackName: 'UnauthorizedStack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }],
});

const accessDeniedError = new Error('Access Denied');
accessDeniedError.name = 'AccessDenied';
cfnClient.on(GetTemplateSummaryCommand, { StackName: 'UnauthorizedStack' }).resolves({ Parameters: [] });
cfnClient.on(GetTemplateCommand, { StackName: 'UnauthorizedStack' }).rejects(accessDeniedError);

garbageCollector = new GarbageCollector({
sdkProvider: new MockSdkProvider(),
ioHelper: ioHost.asHelper('gc'),
action: 'full',
resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' },
bootstrapStackName: 'GarbageStack',
rollbackBufferDays: 0,
createdBufferDays: 0,
type: 's3',
confirm: false,
skipUnauthorizedStacksWhenNonCdk: ['Legacy-*'],
});

await expect(garbageCollector.garbageCollect()).rejects.toThrow(
"Access denied when trying to access stack 'UnauthorizedStack'",
);
});

test('extract stack name from ARN for pattern matching', async () => {
mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] });

const stackArn = 'arn:aws:cloudformation:us-east-1:123456789012:stack/Legacy-App-Stack/12345';
cfnClient.on(ListStacksCommand).resolves({
StackSummaries: [{ StackName: stackArn, StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }],
});

const accessDeniedError = new Error('Access Denied');
accessDeniedError.name = 'AccessDenied';
cfnClient.on(GetTemplateSummaryCommand, { StackName: stackArn }).resolves({ Parameters: [] });
cfnClient.on(GetTemplateCommand, { StackName: stackArn }).rejects(accessDeniedError);

garbageCollector = new GarbageCollector({
sdkProvider: new MockSdkProvider(),
ioHelper: ioHost.asHelper('gc'),
action: 'full',
resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' },
bootstrapStackName: 'GarbageStack',
rollbackBufferDays: 0,
createdBufferDays: 0,
type: 's3',
confirm: false,
skipUnauthorizedStacksWhenNonCdk: ['Legacy-*'],
});

await garbageCollector.garbageCollect();

expect(ioHost.notifySpy).toHaveBeenCalledWith(
expect.objectContaining({ level: 'warn', message: expect.stringContaining('Skipping unauthorized stack') }),
);
});
});

function prepareDefaultCfnMock() {
Expand Down
9 changes: 9 additions & 0 deletions packages/aws-cdk/lib/cli/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,7 @@ export class CdkToolkit {
action: options.action ?? 'full',
type: options.type ?? 'all',
confirm: options.confirm ?? true,
skipUnauthorizedStacksWhenNonCdk: options.skipUnauthorizedStacksWhenNonCdk,
});
await gc.garbageCollect();
}
Expand Down Expand Up @@ -1918,6 +1919,14 @@ export interface GarbageCollectionOptions {
* @default false
*/
readonly confirm?: boolean;

/**
* Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors during garbage collection.
* You must explicitly specify non-CDK stack names - CDK stacks will be rejected to prevent accidental asset deletion.
*
* @default undefined
*/
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
}
export interface MigrateOptions {
/**
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk/lib/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
createdBufferDays: args['created-buffer-days'],
bootstrapStackName: args.toolkitStackName ?? args.bootstrapStackName,
confirm: args.confirm,
skipUnauthorizedStacksWhenNonCdk: arrayFromYargs(args['skip-unauthorized-stacks-when-noncdk']),
});

case 'flags':
Expand Down
6 changes: 6 additions & 0 deletions packages/aws-cdk/lib/cli/parse-command-line-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,12 @@ export function parseCommandLineArguments(args: Array<string>): any {
deprecated: 'use --toolkit-stack-name',
requiresArg: true,
conflicts: 'toolkit-stack-name',
})
.option('skip-unauthorized-stacks-when-noncdk', {
type: 'array',
desc: 'Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors during garbage collection. You must explicitly specify non-CDK stack names - CDK stacks will be rejected.',
default: [],
requiresArg: true,
}),
)
.command('flags [FLAGNAME..]', 'View and toggle feature flags.', (yargs: Argv) =>
Expand Down
Loading