Skip to content

Commit

Permalink
feat: disallow cross account asset publishing in some scenarios (#31623)
Browse files Browse the repository at this point in the history
### Issue # (if applicable)

Closes #<issue number here>.

### Reason for this change



### Description of changes



### Description of how you validated changes



### Checklist
- [ ] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md)

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
iliapolo authored Oct 14, 2024
1 parent 8067294 commit edd031d
Show file tree
Hide file tree
Showing 9 changed files with 259 additions and 34 deletions.
8 changes: 8 additions & 0 deletions packages/aws-cdk/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { StackActivityMonitor, StackActivityProgress } from './util/cloudformation/stack-activity-monitor';
import { TemplateBodyParameter, makeBodyParameter } from './util/template-body-parameter';
import { AssetManifestBuilder } from '../util/asset-manifest-builder';
import { determineAllowCrossAccountAssetPublishing } from './util/checks';
import { publishAssets } from '../util/asset-publishing';

export interface DeployStackResult {
Expand Down Expand Up @@ -287,8 +288,15 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
options.envResources,
options.sdk,
options.overrideTemplate);
let bootstrapStackName: string | undefined;
try {
bootstrapStackName = (await options.envResources.lookupToolkit()).stackName;
} catch (e) {
debug(`Could not determine the bootstrap stack name: ${e}`);
}
await publishAssets(legacyAssets.toManifest(stackArtifact.assembly.directory), options.sdkProvider, stackEnv, {
parallel: options.assetParallelism,
allowCrossAccount: await determineAllowCrossAccountAssetPublishing(options.sdk, bootstrapStackName),
});

if (hotswapMode !== HotswapMode.FULL_DEPLOYMENT) {
Expand Down
18 changes: 16 additions & 2 deletions packages/aws-cdk/lib/api/deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { deployStack, DeployStackResult, destroyStack, DeploymentMethod } from '
import { EnvironmentResources, EnvironmentResourcesRegistry } from './environment-resources';
import { HotswapMode } from './hotswap/common';
import { loadCurrentTemplateWithNestedStacks, loadCurrentTemplate, RootTemplateWithNestedStacks } from './nested-stack-helpers';
import { determineAllowCrossAccountAssetPublishing } from './util/checks';
import { CloudFormationStack, Template, ResourcesToImport, ResourceIdentifierSummaries, stabilizeStack, uploadStackTemplateAssets } from './util/cloudformation';
import { StackActivityMonitor, StackActivityProgress } from './util/cloudformation/stack-activity-monitor';
import { StackEventPoller } from './util/cloudformation/stack-event-poller';
Expand Down Expand Up @@ -384,6 +385,7 @@ export class Deployments {
private readonly publisherCache = new Map<AssetManifest, cdk_assets.AssetPublishing>();
private readonly environmentResources: EnvironmentResourcesRegistry;

private _allowCrossAccountAssetPublishing: boolean | undefined;
constructor(private readonly props: DeploymentsProps) {
this.sdkProvider = props.sdkProvider;
this.environmentResources = new EnvironmentResourcesRegistry(props.toolkitStackName);
Expand Down Expand Up @@ -808,7 +810,10 @@ export class Deployments {
*/
public async publishAssets(asset: cxapi.AssetManifestArtifact, options: PublishStackAssetsOptions) {
const { manifest, stackEnv } = await this.prepareAndValidateAssets(asset, options);
await publishAssets(manifest, this.sdkProvider, stackEnv, options.publishOptions);
await publishAssets(manifest, this.sdkProvider, stackEnv, {
...options.publishOptions,
allowCrossAccount: await this.allowCrossAccountAssetPublishingForEnv(stackEnv),
});
}

/**
Expand Down Expand Up @@ -846,12 +851,21 @@ export class Deployments {

// No need to validate anymore, we already did that during build
const publisher = this.cachedPublisher(assetManifest, stackEnv, options.stackName);
await publisher.publishEntry(asset);
// eslint-disable-next-line no-console
await publisher.publishEntry(asset, { allowCrossAccount: await this.allowCrossAccountAssetPublishingForEnv(stackEnv) });
if (publisher.hasFailures) {
throw new Error(`Failed to publish asset ${asset.id}`);
}
}

private async allowCrossAccountAssetPublishingForEnv(env: cxapi.Environment): Promise<boolean> {
if (this._allowCrossAccountAssetPublishing === undefined) {
const sdk = (await this.cachedSdkForEnvironment(env, Mode.ForReading)).sdk;
this._allowCrossAccountAssetPublishing = await determineAllowCrossAccountAssetPublishing(sdk, this.props.toolkitStackName);
}
return this._allowCrossAccountAssetPublishing;
}

/**
* Return whether a single asset has been published already
*/
Expand Down
74 changes: 74 additions & 0 deletions packages/aws-cdk/lib/api/util/checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { debug } from '../../logging';
import { ISDK } from '../aws-auth/sdk';

export async function determineAllowCrossAccountAssetPublishing(sdk: ISDK, customStackName?: string): Promise<boolean> {
try {
const stackName = customStackName || 'CDKToolkit';
const stackInfo = await getBootstrapStackInfo(sdk, stackName);

if (!stackInfo.hasStagingBucket) {
// indicates an intentional cross account setup
return true;
}

if (stackInfo.bootstrapVersion >= 21) {
// bootstrap stack version 21 contains a fix that will prevent cross
// account publishing on the IAM level
// https://github.com/aws/aws-cdk/pull/30823
return true;
}

// other scenarios are highly irregular and potentially dangerous so we prevent it by
// instructing cdk-assets to detect foreign bucket ownership and reject.
return false;
} catch (e) {
debug(`Error determining cross account asset publishing: ${e}`);
debug('Defaulting to disallowing cross account asset publishing');
return false;
}
}

interface BootstrapStackInfo {
hasStagingBucket: boolean;
bootstrapVersion: number;
}

export async function getBootstrapStackInfo(sdk: ISDK, stackName: string): Promise<BootstrapStackInfo> {
try {
const cfn = sdk.cloudFormation();
const stackResponse = await cfn.describeStacks({ StackName: stackName }).promise();

if (!stackResponse.Stacks || stackResponse.Stacks.length === 0) {
throw new Error(`Toolkit stack ${stackName} not found`);
}

const stack = stackResponse.Stacks[0];
const versionOutput = stack.Outputs?.find(output => output.OutputKey === 'BootstrapVersion');

if (!versionOutput?.OutputValue) {
throw new Error(`Unable to find BootstrapVersion output in the toolkit stack ${stackName}`);
}

const bootstrapVersion = parseInt(versionOutput.OutputValue);
if (isNaN(bootstrapVersion)) {
throw new Error(`Invalid BootstrapVersion value: ${versionOutput.OutputValue}`);
}

// try to get bucketname from the logical resource id
let bucketName: string | undefined;
const resourcesResponse = await cfn.describeStackResources({ StackName: stackName }).promise();
const bucketResource = resourcesResponse.StackResources?.find(resource =>
resource.ResourceType === 'AWS::S3::Bucket',
);
bucketName = bucketResource?.PhysicalResourceId;

let hasStagingBucket = !!bucketName;

return {
hasStagingBucket,
bootstrapVersion,
};
} catch (e) {
throw new Error(`Error retrieving toolkit stack info: ${e}`);
}
}
9 changes: 7 additions & 2 deletions packages/aws-cdk/lib/util/asset-publishing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export interface PublishAssetsOptions {
* @default true To remain backward compatible.
*/
readonly parallel?: boolean;

/**
* Whether cdk-assets is allowed to do cross account publishing.
*/
readonly allowCrossAccount: boolean;
}

/**
Expand All @@ -34,7 +39,7 @@ export async function publishAssets(
manifest: cdk_assets.AssetManifest,
sdk: SdkProvider,
targetEnv: cxapi.Environment,
options: PublishAssetsOptions = {},
options: PublishAssetsOptions,
) {
// This shouldn't really happen (it's a programming error), but we don't have
// the types here to guide us. Do an runtime validation to be super super sure.
Expand All @@ -56,7 +61,7 @@ export async function publishAssets(
publishAssets: true,
quiet: options.quiet,
});
await publisher.publish();
await publisher.publish({ allowCrossAccount: options.allowCrossAccount });
if (publisher.hasFailures) {
throw new Error('Failed to publish one or more assets. See the error messages above for more information.');
}
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
"archiver": "^5.3.2",
"aws-sdk": "^2.1691.0",
"camelcase": "^6.3.0",
"cdk-assets": "^2.154.0",
"cdk-assets": "^2.155.0",
"cdk-from-cfn": "^0.162.0",
"chalk": "^4",
"chokidar": "^3.6.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/aws-cdk/test/api/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const env = {
name: 'mock',
};

jest.mock('../../lib/api/util/checks', () => ({
determineAllowCrossAccountAssetPublishing: jest.fn().mockResolvedValue(true),
}));
let sdk: MockSdkProvider;
let executed: boolean;
let protectedTermination: boolean;
Expand Down
3 changes: 3 additions & 0 deletions packages/aws-cdk/test/api/deploy-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { MockedObject, mockResolvedEnvironment, MockSdk, MockSdkProvider, SyncHa
import { NoBootstrapStackEnvironmentResources } from '../../lib/api/environment-resources';

jest.mock('../../lib/api/hotswap-deployments');
jest.mock('../../lib/api/util/checks', () => ({
determineAllowCrossAccountAssetPublishing: jest.fn().mockResolvedValue(true),
}));

const FAKE_STACK = testStack({
stackName: 'withouterrors',
Expand Down
123 changes: 123 additions & 0 deletions packages/aws-cdk/test/api/util/checks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import * as AWS from 'aws-sdk';
import * as AWSMock from 'aws-sdk-mock';
import { ISDK } from '../../../lib/api/aws-auth';
import { determineAllowCrossAccountAssetPublishing, getBootstrapStackInfo } from '../../../lib/api/util/checks';

describe('determineAllowCrossAccountAssetPublishing', () => {
let mockSDK: ISDK;

beforeEach(() => {
mockSDK = {
cloudFormation: () => new AWS.CloudFormation(),
} as ISDK;
});

afterEach(() => {
AWSMock.restore();
});

it('should return true when hasStagingBucket is false', async () => {
AWSMock.mock('CloudFormation', 'describeStacks', (_params: any, callback: Function) => {
callback(null, {
Stacks: [{
Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '1' }],
}],
});
});

AWSMock.mock('CloudFormation', 'describeStackResources', (_params: any, callback: Function) => {
callback(null, { StackResources: [] });
});

const result = await determineAllowCrossAccountAssetPublishing(mockSDK);
expect(result).toBe(true);
});

it('should return true when bootstrap version is >= 21', async () => {
AWSMock.mock('CloudFormation', 'describeStacks', (_params: any, callback: Function) => {
callback(null, {
Stacks: [{
Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '21' }],
}],
});
});

AWSMock.mock('CloudFormation', 'describeStackResources', (_params: any, callback: Function) => {
callback(null, { StackResources: [{ ResourceType: 'AWS::S3::Bucket', PhysicalResourceId: 'some-bucket' }] });
});

const result = await determineAllowCrossAccountAssetPublishing(mockSDK);
expect(result).toBe(true);
});

it('should return false for other scenarios', async () => {
AWSMock.mock('CloudFormation', 'describeStacks', (_params: any, callback: Function) => {
callback(null, {
Stacks: [{
Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '20' }],
}],
});
});

AWSMock.mock('CloudFormation', 'describeStackResources', (_params: any, callback: Function) => {
callback(null, { StackResources: [{ ResourceType: 'AWS::S3::Bucket', PhysicalResourceId: 'some-bucket' }] });
});

const result = await determineAllowCrossAccountAssetPublishing(mockSDK);
expect(result).toBe(false);
});
});

describe('getBootstrapStackInfo', () => {
let mockSDK: ISDK;

beforeEach(() => {
mockSDK = {
cloudFormation: () => new AWS.CloudFormation(),
} as ISDK;
});

afterEach(() => {
AWSMock.restore();
});

it('should return correct BootstrapStackInfo', async () => {
AWSMock.mock('CloudFormation', 'describeStacks', (_params: any, callback: Function) => {
callback(null, {
Stacks: [{
Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '21' }],
}],
});
});

AWSMock.mock('CloudFormation', 'describeStackResources', (_params: any, callback: Function) => {
callback(null, { StackResources: [{ ResourceType: 'AWS::S3::Bucket', PhysicalResourceId: 'some-bucket' }] });
});

const result = await getBootstrapStackInfo(mockSDK, 'CDKToolkit');
expect(result).toEqual({
hasStagingBucket: true,
bootstrapVersion: 21,
});
});

it('should throw error when stack is not found', async () => {
AWSMock.mock('CloudFormation', 'describeStacks', (_params: any, callback: Function) => {
callback(null, { Stacks: [] });
});

await expect(getBootstrapStackInfo(mockSDK, 'CDKToolkit')).rejects.toThrow('Toolkit stack CDKToolkit not found');
});

it('should throw error when BootstrapVersion output is missing', async () => {
AWSMock.mock('CloudFormation', 'describeStacks', (_params: any, callback: Function) => {
callback(null, {
Stacks: [{
Outputs: [],
}],
});
});

await expect(getBootstrapStackInfo(mockSDK, 'CDKToolkit')).rejects.toThrow('Unable to find BootstrapVersion output in the toolkit stack CDKToolkit');
});
});
Loading

0 comments on commit edd031d

Please sign in to comment.