diff --git a/packages/@aws-cdk/aws-autoscaling/README.md b/packages/@aws-cdk/aws-autoscaling/README.md index 67e55eee91a9f..d1a75f16e493b 100644 --- a/packages/@aws-cdk/aws-autoscaling/README.md +++ b/packages/@aws-cdk/aws-autoscaling/README.md @@ -378,6 +378,23 @@ new autoscaling.AutoScalingGroup(stack, 'ASG', { }); ``` +## Configuring Instance Metadata Service (IMDS) + +### Toggling IMDSv1 + +You can configure [EC2 Instance Metadata Service](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) options to either +allow both IMDSv1 and IMDSv2 or enforce IMDSv2 when interacting with the IMDS. To do this, you can use the `AutoScalingGroupImdsAspect`. + +The following example demonstrates how to use the `AutoScalingGroupImdsAspect` to disable IMDSv1 (thus enforcing IMDSv2) for all AutoScalingGroups in a stack: + +```ts +const aspect = new autoscaling.AutoScalingGroupImdsAspect({ + enableImdsV1: false, +}); + +Aspects.of(stack).add(aspect); +``` + ## Future work * [ ] CloudWatch Events (impossible to add currently as the AutoScalingGroup ARN is diff --git a/packages/@aws-cdk/aws-autoscaling/lib/aspects/imds-aspect.ts b/packages/@aws-cdk/aws-autoscaling/lib/aspects/imds-aspect.ts new file mode 100644 index 0000000000000..1a50ddbfac6b0 --- /dev/null +++ b/packages/@aws-cdk/aws-autoscaling/lib/aspects/imds-aspect.ts @@ -0,0 +1,83 @@ +import * as cdk from '@aws-cdk/core'; +import { AutoScalingGroup } from '../auto-scaling-group'; +import { CfnLaunchConfiguration } from '../autoscaling.generated'; + +/** + * Properties for `ImdsAspect`. + */ +interface ImdsAspectProps { + /** + * Whether IMDSv1 should be enabled or not. + */ + readonly enableImdsV1: boolean; + + /** + * Whether warning annotations from this Aspect should be suppressed or not. + * @default false + */ + readonly suppressWarnings?: boolean; +} + +/** + * Base class for IMDS configuration Aspect. + */ +abstract class ImdsAspect implements cdk.IAspect { + protected readonly enableImdsV1: boolean; + protected readonly suppressWarnings: boolean; + + constructor(props: ImdsAspectProps) { + this.enableImdsV1 = props.enableImdsV1; + this.suppressWarnings = props.suppressWarnings ?? false; + } + + abstract visit(node: cdk.IConstruct): void; + + /** + * Adds a warning annotation to a node, unless `suppressWarnings` is true. + * @param node The scope to add the warning to. + * @param message The warning message. + */ + protected warn(node: cdk.IConstruct, message: string) { + if (this.suppressWarnings !== true) { + cdk.Annotations.of(node).addWarning(`${ImdsAspect.name} failed on node ${node.node.id}: ${message}`); + } + } +} + +/** + * Properties for `AutoScalingGroupImdsAspect`. + */ +export interface AutoScalingGroupImdsAspectProps extends ImdsAspectProps {} + +/** + * Aspect that applies IMDS configuration to AutoScalingGroups. + */ +export class AutoScalingGroupImdsAspect extends ImdsAspect { + constructor(props: AutoScalingGroupImdsAspectProps) { + super(props); + } + + visit(node: cdk.IConstruct): void { + /* istanbul ignore next */ + if (node === undefined || !(node instanceof AutoScalingGroup)) { + return; + } + + const launchConfig = node.node.tryFindChild('LaunchConfig') as CfnLaunchConfiguration; + if (launchConfig.metadataOptions !== undefined && implementsIResolvable(launchConfig.metadataOptions)) { + this.warn(node, 'CfnLaunchConfiguration.MetadataOptions field is a CDK token.'); + return; + } + + launchConfig.metadataOptions = { + ...launchConfig.metadataOptions, + httpTokens: this.enableImdsV1 ? 'optional' : 'required', + }; + } +} + +function implementsIResolvable(obj: any): boolean { + return 'resolve' in obj && typeof(obj.resolve) === 'function' && + 'creationStack' in obj && Array.isArray(obj.creationStack) && + 'toString' in obj && typeof(obj.toString) === 'function'; +} diff --git a/packages/@aws-cdk/aws-autoscaling/lib/aspects/index.ts b/packages/@aws-cdk/aws-autoscaling/lib/aspects/index.ts new file mode 100644 index 0000000000000..25a82e046399c --- /dev/null +++ b/packages/@aws-cdk/aws-autoscaling/lib/aspects/index.ts @@ -0,0 +1 @@ +export * from './imds-aspect'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-autoscaling/lib/index.ts b/packages/@aws-cdk/aws-autoscaling/lib/index.ts index 69fede92e300b..186d1a3058fae 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/index.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/index.ts @@ -1,3 +1,4 @@ +export * from './aspects'; export * from './auto-scaling-group'; export * from './schedule'; export * from './lifecycle-hook'; diff --git a/packages/@aws-cdk/aws-autoscaling/package.json b/packages/@aws-cdk/aws-autoscaling/package.json index 67fb9d4e1dd7e..920290b188170 100644 --- a/packages/@aws-cdk/aws-autoscaling/package.json +++ b/packages/@aws-cdk/aws-autoscaling/package.json @@ -74,12 +74,14 @@ "license": "Apache-2.0", "devDependencies": { "@types/jest": "^26.0.24", + "@types/sinon": "^9.0.11", "@aws-cdk/cx-api": "0.0.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", "jest": "^26.6.3", "pkglint": "0.0.0", + "sinon": "^9.2.4", "@aws-cdk/cloud-assembly-schema": "0.0.0", "@aws-cdk/assert-internal": "0.0.0" }, diff --git a/packages/@aws-cdk/aws-autoscaling/test/aspects/imds-aspect.test.ts b/packages/@aws-cdk/aws-autoscaling/test/aspects/imds-aspect.test.ts new file mode 100644 index 0000000000000..875fe372a8d75 --- /dev/null +++ b/packages/@aws-cdk/aws-autoscaling/test/aspects/imds-aspect.test.ts @@ -0,0 +1,110 @@ +import { + expect as expectCDK, + haveResourceLike, +} from '@aws-cdk/assert-internal'; +import '@aws-cdk/assert-internal/jest'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as cdk from '@aws-cdk/core'; +import * as sinon from 'sinon'; +import { + AutoScalingGroup, + AutoScalingGroupImdsAspect, + CfnLaunchConfiguration, +} from '../../lib'; + +describe('ImdsAspect', () => { + let app: cdk.App; + let stack: cdk.Stack; + let vpc: ec2.Vpc; + + beforeEach(() => { + app = new cdk.App(); + stack = new cdk.Stack(app, 'Stack'); + vpc = new ec2.Vpc(stack, 'Vpc'); + }); + + test('suppresses warnings', () => { + // GIVEN + const aspect = new AutoScalingGroupImdsAspect({ + enableImdsV1: true, + suppressWarnings: true, + }); + const errmsg = 'ERROR'; + const stub = sinon.stub(aspect, 'visit').callsFake((node) => { + // @ts-ignore + aspect.warn(node, errmsg); + }); + const construct = new cdk.Construct(stack, 'Construct'); + + // WHEN + aspect.visit(construct); + + // THEN + expect(stub.calledOnce).toBeTruthy(); + expect(construct.node.metadataEntry).not.toContainEqual({ + data: expect.stringContaining(errmsg), + type: 'aws:cdk:warning', + trace: undefined, + }); + }); + + describe('AutoScalingGroupImdsAspect', () => { + test('warns when metadataOptions is a token', () => { + // GIVEN + const asg = new AutoScalingGroup(stack, 'AutoScalingGroup', { + vpc, + instanceType: new ec2.InstanceType('t2.micro'), + machineImage: ec2.MachineImage.latestAmazonLinux(), + }); + const launchConfig = asg.node.tryFindChild('LaunchConfig') as CfnLaunchConfiguration; + launchConfig.metadataOptions = fakeToken(); + const aspect = new AutoScalingGroupImdsAspect({ enableImdsV1: false }); + + // WHEN + aspect.visit(asg); + + // THEN + expect(asg.node.metadataEntry).toContainEqual({ + data: expect.stringContaining('CfnLaunchConfiguration.MetadataOptions field is a CDK token.'), + type: 'aws:cdk:warning', + trace: undefined, + }); + expectCDK(stack).notTo(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + MetadataOptions: { + HttpTokens: 'required', + }, + })); + }); + + test.each([ + [true], + [false], + ])('toggles IMDSv1 (enabled=%s)', (enableImdsV1: boolean) => { + // GIVEN + const asg = new AutoScalingGroup(stack, 'AutoScalingGroup', { + vpc, + instanceType: new ec2.InstanceType('t2.micro'), + machineImage: ec2.MachineImage.latestAmazonLinux(), + }); + const aspect = new AutoScalingGroupImdsAspect({ enableImdsV1 }); + + // WHEN + aspect.visit(asg); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + MetadataOptions: { + HttpTokens: enableImdsV1 ? 'optional' : 'required', + }, + })); + }); + }); +}); + +function fakeToken(): cdk.IResolvable { + return { + creationStack: [], + resolve: (_c) => {}, + toString: () => '', + }; +}