diff --git a/packages/@aws-cdk/aws-route53resolver/README.md b/packages/@aws-cdk/aws-route53resolver/README.md index 9cf4ab7748b3d..a3bf5946ead7d 100644 --- a/packages/@aws-cdk/aws-route53resolver/README.md +++ b/packages/@aws-cdk/aws-route53resolver/README.md @@ -9,10 +9,101 @@ > > [CFN Resources]: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + --- +## DNS Firewall + +With Route 53 Resolver DNS Firewall, you can filter and regulate outbound DNS traffic for your +virtual private connections (VPCs). To do this, you create reusable collections of filtering rules +in DNS Firewall rule groups and associate the rule groups to your VPC. + +DNS Firewall provides protection for outbound DNS requests from your VPCs. These requests route +through Resolver for domain name resolution. A primary use of DNS Firewall protections is to help +prevent DNS exfiltration of your data. DNS exfiltration can happen when a bad actor compromises +an application instance in your VPC and then uses DNS lookup to send data out of the VPC to a domain +that they control. With DNS Firewall, you can monitor and control the domains that your applications +can query. You can deny access to the domains that you know to be bad and allow all other queries +to pass through. Alternately, you can deny access to all domains except for the ones that you +explicitly trust. + +### Domain lists + +Domain lists can be created using a list of strings, a text file stored in Amazon S3 or a local +text file: + +```ts +const blockList = new route53resolver.FirewallDomainList(this, 'BlockList', { + domains: route53resolver.FirewallDomains.fromList(['bad-domain.com', 'bot-domain.net']), +}); + +const s3List = new route53resolver.FirewallDomainList(this, 'S3List', { + domains: route53resolver.FirewallDomains.fromS3Url('s3://bucket/prefix/object'), +}); + +const assetList = new route53resolver.FirewallDomainList(this, 'AssetList', { + domains: route53resolver.FirewallDomains.fromAsset('/path/to/domains.txt'), +}); +``` + +The file must be a text file and must contain a single domain per line. + +Use `FirewallDomainList.fromFirewallDomainListId()` to import an existing or [AWS managed domain list](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-dns-firewall-managed-domain-lists.html): + +```ts +// AWSManagedDomainsMalwareDomainList in us-east-1 +const malwareList = route53resolver.FirewallDomainList.fromFirewallDomainListId(this, 'Malware', 'rslvr-fdl-2c46f2ecbfec4dcc'); +``` + +### Rule group + +Create a rule group: + +```ts +new route53resolver.FirewallRuleGroup(this, 'RuleGroup', { + rules: [ + { + priority: 10, + firewallDomainList: myBlockList, + // block and reply with NODATA + action: route53resolver.FirewallRuleAction.block(), + }, + ], +}); +``` + +Rules can be added at construction time or using `addRule()`: + +```ts +ruleGroup.addRule({ + priority: 10, + firewallDomainList: blockList, + // block and reply with NXDOMAIN + action: route53resolver.FirewallRuleAction.block(route53resolver.DnsBlockResponse.nxDomain()), +}); + +ruleGroup.addRule({ + priority: 20, + firewallDomainList: blockList, + // block and override DNS response with a custom domain + action: route53resolver.FirewallRuleAction.block(route53resolver.DnsBlockResponse.override('amazon.com')), +}); +``` + +Use `associate()` to associate a rule group with a VPC: + ```ts -import * as route53resolver from '@aws-cdk/aws-route53resolver'; +ruleGroup.associate({ + priority: 101, + vpc: myVpc, +}) ``` diff --git a/packages/@aws-cdk/aws-route53resolver/lib/firewall-domain-list.ts b/packages/@aws-cdk/aws-route53resolver/lib/firewall-domain-list.ts new file mode 100644 index 0000000000000..a6303b2c78114 --- /dev/null +++ b/packages/@aws-cdk/aws-route53resolver/lib/firewall-domain-list.ts @@ -0,0 +1,234 @@ +import * as path from 'path'; +import { IBucket } from '@aws-cdk/aws-s3'; +import { Asset } from '@aws-cdk/aws-s3-assets'; +import { IResource, Resource, Token } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnFirewallDomainList } from './route53resolver.generated'; + +/** + * A Firewall Domain List + */ +export interface IFirewallDomainList extends IResource { + /** + * The ID of the domain list + * + * @attribute + */ + readonly firewallDomainListId: string; +} + +/** + * Properties for a Firewall Domain List + */ +export interface FirewallDomainListProps { + /** + * A name for the domain list + * + * @default - a CloudFormation generated name + */ + readonly name?: string; + + /** + * A list of domains + */ + readonly domains: FirewallDomains; +} + +/** + * A list of domains + */ +export abstract class FirewallDomains { + /** + * Firewall domains created from a list of domains + * + * @param list the list of domains + */ + public static fromList(list: string[]): FirewallDomains { + for (const domain of list) { + if (!/^[\w-.]+$/.test(domain)) { + throw new Error(`Invalid domain: ${domain}. Valid characters: A-Z, a-z, 0-9, _, -, .`); + } + } + + return { + bind(_scope: Construct): DomainsConfig { + return { domains: list }; + }, + }; + } + + /** + * Firewall domains created from the URL of a file stored in Amazon S3. + * The file must be a text file and must contain a single domain per line. + * The content type of the S3 object must be `plain/text`. + * + * @param url S3 bucket url (s3://bucket/prefix/objet). + */ + public static fromS3Url(url: string): FirewallDomains { + if (!Token.isUnresolved(url) && !url.startsWith('s3://')) { + throw new Error(`The S3 URL must start with s3://, got ${url}`); + } + + return { + bind(_scope: Construct): DomainsConfig { + return { domainFileUrl: url }; + }, + }; + } + + /** + * Firewall domains created from a file stored in Amazon S3. + * The file must be a text file and must contain a single domain per line. + * The content type of the S3 object must be `plain/text`. + * + * @param bucket S3 bucket + * @param key S3 key + */ + public static fromS3(bucket: IBucket, key: string): FirewallDomains { + return this.fromS3Url(bucket.s3UrlForObject(key)); + } + + /** + * Firewall domains created from a local disk path to a text file. + * The file must be a text file (`.txt` extension) and must contain a single + * domain per line. It will be uploaded to S3. + * + * @param assetPath path to the text file + */ + public static fromAsset(assetPath: string): FirewallDomains { + // cdk-assets will correctly set the content type for the S3 object + // if the file has the correct extension + if (path.extname(assetPath) !== '.txt') { + throw new Error(`FirewallDomains.fromAsset() expects a file with the .txt extension, got ${assetPath}`); + } + + return { + bind(scope: Construct): DomainsConfig { + const asset = new Asset(scope, 'Domains', { path: assetPath }); + + if (!asset.isFile) { + throw new Error('FirewallDomains.fromAsset() expects a file'); + } + + return { domainFileUrl: asset.s3ObjectUrl }; + }, + }; + + } + + /** Binds the domains to a domain list */ + public abstract bind(scope: Construct): DomainsConfig; +} + +/** + * Domains configuration + */ +export interface DomainsConfig { + /** + * The fully qualified URL or URI of the file stored in Amazon S3 that contains + * the list of domains to import. The file must be a text file and must contain + * a single domain per line. The content type of the S3 object must be `plain/text`. + * + * @default - use `domains` + */ + readonly domainFileUrl?: string; + + /** + * A list of domains + * + * @default - use `domainFileUrl` + */ + readonly domains?: string[]; +} + +/** + * A Firewall Domain List + */ +export class FirewallDomainList extends Resource implements IFirewallDomainList { + /** + * Import an existing Firewall Rule Group + */ + public static fromFirewallDomainListId(scope: Construct, id: string, firewallDomainListId: string): IFirewallDomainList { + class Import extends Resource implements IFirewallDomainList { + public readonly firewallDomainListId = firewallDomainListId; + } + return new Import(scope, id); + } + + public readonly firewallDomainListId: string; + + /** + * The ARN (Amazon Resource Name) of the domain list + * @attribute + */ + public readonly firewallDomainListArn: string; + + /** + * The date and time that the domain list was created + * @attribute + */ + public readonly firewallDomainListCreationTime: string; + + /** + * The creator request ID + * @attribute + */ + public readonly firewallDomainListCreatorRequestId: string; + + /** + * The number of domains in the list + * @attribute + */ + public readonly firewallDomainListDomainCount: number; + + /** + * The owner of the list, used only for lists that are not managed by you. + * For example, the managed domain list `AWSManagedDomainsMalwareDomainList` + * has the managed owner name `Route 53 Resolver DNS Firewall`. + * @attribute + */ + public readonly firewallDomainListManagedOwnerName: string; + + /** + * The date and time that the domain list was last modified + * @attribute + */ + public readonly firewallDomainListModificationTime: string; + + /** + * The status of the domain list + * @attribute + */ + public readonly firewallDomainListStatus: string; + + /** + * Additional information about the status of the rule group + * @attribute + */ + public readonly firewallDomainListStatusMessage: string; + + constructor(scope: Construct, id: string, props: FirewallDomainListProps) { + super(scope, id); + + if (props.name && !Token.isUnresolved(props.name) && !/^[\w-.]{1,128}$/.test(props.name)) { + throw new Error(`Invalid domain list name: ${props.name}. The name must have 1-128 characters. Valid characters: A-Z, a-z, 0-9, _, -, .`); + } + + const domainsConfig = props.domains.bind(this); + const domainList = new CfnFirewallDomainList(this, 'Resource', { + name: props.name, + domainFileUrl: domainsConfig.domainFileUrl, + domains: domainsConfig.domains, + }); + + this.firewallDomainListId = domainList.attrId; + this.firewallDomainListArn = domainList.attrArn; + this.firewallDomainListCreationTime = domainList.attrCreationTime; + this.firewallDomainListCreatorRequestId = domainList.attrCreatorRequestId; + this.firewallDomainListDomainCount = domainList.attrDomainCount; + this.firewallDomainListManagedOwnerName = domainList.attrManagedOwnerName; + this.firewallDomainListModificationTime = domainList.attrModificationTime; + this.firewallDomainListStatus = domainList.attrStatus; + this.firewallDomainListStatusMessage = domainList.attrStatusMessage; + } +} diff --git a/packages/@aws-cdk/aws-route53resolver/lib/firewall-rule-group-association.ts b/packages/@aws-cdk/aws-route53resolver/lib/firewall-rule-group-association.ts new file mode 100644 index 0000000000000..10281eba1dda1 --- /dev/null +++ b/packages/@aws-cdk/aws-route53resolver/lib/firewall-rule-group-association.ts @@ -0,0 +1,129 @@ +import { IVpc } from '@aws-cdk/aws-ec2'; +import { Resource, Token } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IFirewallRuleGroup } from './firewall-rule-group'; +import { CfnFirewallRuleGroupAssociation } from './route53resolver.generated'; + +/** + * Options for a Firewall Rule Group Association + */ +export interface FirewallRuleGroupAssociationOptions { + /** + * If enabled, this setting disallows modification or removal of the + * association, to help prevent against accidentally altering DNS firewall + * protections. + * + * @default true + */ + readonly mutationProtection?: boolean; + + /** + * The name of the association + * + * @default - a CloudFormation generated name + */ + readonly name?: string; + + /** + * The setting that determines the processing order of the rule group among + * the rule groups that are associated with a single VPC. DNS Firewall filters VPC + * traffic starting from rule group with the lowest numeric priority setting. + * + * This value must be greater than 100 and less than 9,000 + */ + readonly priority: number; + + /** + * The VPC that to associate with the rule group. + */ + readonly vpc: IVpc; +} + +/** + * Properties for a Firewall Rule Group Association + */ +export interface FirewallRuleGroupAssociationProps extends FirewallRuleGroupAssociationOptions { + /** + * The firewall rule group which must be associated + */ + readonly firewallRuleGroup: IFirewallRuleGroup; +} + +/** + * A Firewall Rule Group Association + */ +export class FirewallRuleGroupAssociation extends Resource { + /** + * The ARN (Amazon Resource Name) of the association + * @attribute + */ + public readonly firewallRuleGroupAssociationArn: string; + + /** + * The date and time that the association was created + * @attribute + */ + public readonly firewallRuleGroupAssociationCreationTime: string; + + /** + * The creator request ID + * @attribute + */ + public readonly firewallRuleGroupAssociationCreatorRequestId: string; + + /** + * The ID of the association + * + * @attribute + */ + public readonly firewallRuleGroupAssociationId: string; + + /** + * The owner of the association, used only for lists that are not managed by you. + * If you use AWS Firewall Manager to manage your firewallls from DNS Firewall, + * then this reports Firewall Manager as the managed owner. + * @attribute + */ + public readonly firewallRuleGroupAssociationManagedOwnerName: string; + + /** + * The date and time that the association was last modified + * @attribute + */ + public readonly firewallRuleGroupAssociationModificationTime: string; + + /** + * The status of the association + * @attribute + */ + public readonly firewallRuleGroupAssociationStatus: string; + + /** + * Additional information about the status of the association + * @attribute + */ + public readonly firewallRuleGroupAssociationStatusMessage: string; + + constructor(scope: Construct, id: string, props: FirewallRuleGroupAssociationProps) { + super(scope, id); + + if (!Token.isUnresolved(props.priority) && (props.priority <= 100 || props.priority >= 9000)) { + throw new Error(`Priority must be greater than 100 and less than 9000, got ${props.priority}`); + } + + const association = new CfnFirewallRuleGroupAssociation(this, 'Resource', { + firewallRuleGroupId: props.firewallRuleGroup.firewallRuleGroupId, + priority: props.priority, + vpcId: props.vpc.vpcId, + }); + + this.firewallRuleGroupAssociationArn = association.attrArn; + this.firewallRuleGroupAssociationCreationTime = association.attrCreationTime; + this.firewallRuleGroupAssociationCreatorRequestId = association.attrCreatorRequestId; + this.firewallRuleGroupAssociationId = association.attrId; + this.firewallRuleGroupAssociationManagedOwnerName = association.attrManagedOwnerName; + this.firewallRuleGroupAssociationModificationTime = association.attrModificationTime; + this.firewallRuleGroupAssociationStatus = association.attrStatus; + this.firewallRuleGroupAssociationStatusMessage = association.attrStatusMessage; + } +} diff --git a/packages/@aws-cdk/aws-route53resolver/lib/firewall-rule-group.ts b/packages/@aws-cdk/aws-route53resolver/lib/firewall-rule-group.ts new file mode 100644 index 0000000000000..4346e23e46435 --- /dev/null +++ b/packages/@aws-cdk/aws-route53resolver/lib/firewall-rule-group.ts @@ -0,0 +1,277 @@ +import { Duration, IResource, Lazy, Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IFirewallDomainList } from './firewall-domain-list'; +import { FirewallRuleGroupAssociation, FirewallRuleGroupAssociationOptions } from './firewall-rule-group-association'; +import { CfnFirewallRuleGroup } from './route53resolver.generated'; + +/** + * A Firewall Rule Group + */ +export interface IFirewallRuleGroup extends IResource { + /** + * The ID of the rule group + * + * @attribute + */ + readonly firewallRuleGroupId: string; +} + +/** + * Properties for a Firewall Rule Group + */ +export interface FirewallRuleGroupProps { + /** + * The name of the rule group. + * + * @default - a CloudFormation generated name + */ + readonly name?: string; + + /** + * A list of rules for this group + * + * @default - no rules + */ + readonly rules?: FirewallRule[]; +} + +/** + * A Firewall Rule + */ +export interface FirewallRule { + /** + * The action for this rule + */ + readonly action: FirewallRuleAction; + + /** + * The domain list for this rule + */ + readonly firewallDomainList: IFirewallDomainList; + + /** + * The priority of the rule in the rule group. This value must be unique within + * the rule group. + */ + readonly priority: number; +} + +/** + * A Firewall Rule + */ +export abstract class FirewallRuleAction { + /** + * Permit the request to go through + */ + public static allow(): FirewallRuleAction { + return { action: 'ALLOW' }; + } + + /** + * Permit the request to go through but send an alert to the logs + */ + public static alert(): FirewallRuleAction { + return { action: 'ALERT' }; + } + + /** + * Disallow the request + * + * @param [response=DnsBlockResponse.noData()] The way that you want DNS Firewall to block the request + */ + public static block(response?: DnsBlockResponse): FirewallRuleAction { + return { + action: 'BLOCK', + blockResponse: response ?? DnsBlockResponse.noData(), + }; + } + + /** + * The action that DNS Firewall should take on a DNS query when it matches + * one of the domains in the rule's domain list + */ + public abstract readonly action: string; + + /** + * The way that you want DNS Firewall to block the request + */ + public abstract readonly blockResponse?: DnsBlockResponse; +} + +/** + * The way that you want DNS Firewall to block the request + */ +export abstract class DnsBlockResponse { + /** + * Respond indicating that the query was successful, but no + * response is available for it. + */ + public static noData(): DnsBlockResponse { + return { blockResponse: 'NODATA' }; + } + + /** + * Respond indicating that the domain name that's in the query + * doesn't exist. + */ + public static nxDomain(): DnsBlockResponse { + return { blockResponse: 'NXDOMAIN' }; + } + + /** + * Provides a custom override response to the query + * + * @param domain The custom DNS record to send back in response to the query + * @param [ttl=0] The recommended amount of time for the DNS resolver or + * web browser to cache the provided override record + */ + public static override(domain: string, ttl?: Duration): DnsBlockResponse { + return { + blockResponse: 'OVERRIDE', + blockOverrideDnsType: 'CNAME', + blockOverrideDomain: domain, + blockOverrideTtl: ttl ?? Duration.seconds(0), + }; + } + + /** The DNS record's type */ + public abstract readonly blockOverrideDnsType?: string; + + /** The custom DNS record to send back in response to the query */ + public abstract readonly blockOverrideDomain?: string; + + /** + * The recommended amount of time for the DNS resolver or + * web browser to cache the provided override record + */ + public abstract readonly blockOverrideTtl?: Duration; + + /** The way that you want DNS Firewall to block the request */ + public abstract readonly blockResponse?: string; +} + +/** + * A Firewall Rule Group + */ +export class FirewallRuleGroup extends Resource implements IFirewallRuleGroup { + /** + * Import an existing Firewall Rule Group + */ + public static fromFirewallRuleGroupId(scope: Construct, id: string, firewallRuleGroupId: string): IFirewallRuleGroup { + class Import extends Resource implements IFirewallRuleGroup { + public readonly firewallRuleGroupId = firewallRuleGroupId; + } + return new Import(scope, id); + } + + public readonly firewallRuleGroupId: string; + + /** + * The ARN (Amazon Resource Name) of the rule group + * @attribute + */ + public readonly firewallRuleGroupArn: string; + + /** + * The date and time that the rule group was created + * @attribute + */ + public readonly firewallRuleGroupCreationTime: string; + + /** + * The creator request ID + * @attribute + */ + public readonly firewallRuleGroupCreatorRequestId: string; + + /** + * The date and time that the rule group was last modified + * @attribute + */ + public readonly firewallRuleGroupModificationTime: string; + + /** + * The AWS account ID for the account that created the rule group + * @attribute + */ + public readonly firewallRuleGroupOwnerId: string; + + /** + * The number of rules in the rule group + * @attribute + */ + public readonly firewallRuleGroupRuleCount: number; + + /** + * Whether the rule group is shared with other AWS accounts, + * or was shared with the current account by another AWS account + * @attribute + */ + public readonly firewallRuleGroupShareStatus: string; + + /** + * The status of the rule group + * @attribute + */ + public readonly firewallRuleGroupStatus: string; + + /** + * Additional information about the status of the rule group + * @attribute + */ + public readonly firewallRuleGroupStatusMessage: string; + + private readonly rules: FirewallRule[]; + + constructor(scope: Construct, id: string, props: FirewallRuleGroupProps = {}) { + super(scope, id); + + this.rules = props.rules ?? []; + + const ruleGroup = new CfnFirewallRuleGroup(this, 'Resource', { + name: props.name, + firewallRules: Lazy.any({ produce: () => this.rules.map(renderRule) }), + }); + + this.firewallRuleGroupId = ruleGroup.attrId; + this.firewallRuleGroupArn = ruleGroup.attrArn; + this.firewallRuleGroupCreationTime = ruleGroup.attrCreationTime; + this.firewallRuleGroupCreatorRequestId = ruleGroup.attrCreatorRequestId; + this.firewallRuleGroupModificationTime = ruleGroup.attrModificationTime; + this.firewallRuleGroupOwnerId = ruleGroup.attrOwnerId; + this.firewallRuleGroupRuleCount = ruleGroup.attrRuleCount; + this.firewallRuleGroupShareStatus = ruleGroup.attrShareStatus; + this.firewallRuleGroupStatus = ruleGroup.attrStatus; + this.firewallRuleGroupStatusMessage = ruleGroup.attrStatusMessage; + } + + /** + * Adds a rule to this group + */ + public addRule(rule: FirewallRule): FirewallRuleGroup { + this.rules.push(rule); + return this; + } + + /** + * Associates this Firewall Rule Group with a VPC + */ + public associate(id: string, props: FirewallRuleGroupAssociationOptions): FirewallRuleGroupAssociation { + return new FirewallRuleGroupAssociation(this, id, { + ...props, + firewallRuleGroup: this, + }); + } +} + +function renderRule(rule: FirewallRule): CfnFirewallRuleGroup.FirewallRuleProperty { + return { + action: rule.action.action, + firewallDomainListId: rule.firewallDomainList.firewallDomainListId, + priority: rule.priority, + blockOverrideDnsType: rule.action.blockResponse?.blockOverrideDnsType, + blockOverrideDomain: rule.action.blockResponse?.blockOverrideDomain, + blockOverrideTtl: rule.action.blockResponse?.blockOverrideTtl?.toSeconds(), + blockResponse: rule.action.blockResponse?.blockResponse, + }; +} diff --git a/packages/@aws-cdk/aws-route53resolver/lib/index.ts b/packages/@aws-cdk/aws-route53resolver/lib/index.ts index 7a6d65433e61f..97baf0759d24a 100644 --- a/packages/@aws-cdk/aws-route53resolver/lib/index.ts +++ b/packages/@aws-cdk/aws-route53resolver/lib/index.ts @@ -1,2 +1,6 @@ +export * from './firewall-domain-list'; +export * from './firewall-rule-group'; +export * from './firewall-rule-group-association'; + // AWS::Route53Resolver CloudFormation Resources: export * from './route53resolver.generated'; diff --git a/packages/@aws-cdk/aws-route53resolver/package.json b/packages/@aws-cdk/aws-route53resolver/package.json index a515fecb3c120..5e24de4caaffb 100644 --- a/packages/@aws-cdk/aws-route53resolver/package.json +++ b/packages/@aws-cdk/aws-route53resolver/package.json @@ -76,15 +76,22 @@ "devDependencies": { "@types/jest": "^26.0.24", "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", "pkglint": "0.0.0", "@aws-cdk/assertions": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, "peerDependencies": { + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, @@ -92,7 +99,14 @@ "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "experimental", - "maturity": "cfn-only", + "maturity": "experimental", + "awslint": { + "exclude": [ + "props-physical-name:@aws-cdk/aws-route53resolver.FirewallDomainListProps", + "props-physical-name:@aws-cdk/aws-route53resolver.FirewallRuleGroupProps", + "props-physical-name:@aws-cdk/aws-route53resolver.FirewallRuleGroupAssociationProps" + ] + }, "awscdkio": { "announce": false }, diff --git a/packages/@aws-cdk/aws-route53resolver/test/domains.txt b/packages/@aws-cdk/aws-route53resolver/test/domains.txt new file mode 100644 index 0000000000000..872337c768ca7 --- /dev/null +++ b/packages/@aws-cdk/aws-route53resolver/test/domains.txt @@ -0,0 +1,4 @@ +amazon.com +amazon.co.uk +amazon.fr +amazon.de diff --git a/packages/@aws-cdk/aws-route53resolver/test/firewall-domain-list.test.ts b/packages/@aws-cdk/aws-route53resolver/test/firewall-domain-list.test.ts new file mode 100644 index 0000000000000..3806a59c670ba --- /dev/null +++ b/packages/@aws-cdk/aws-route53resolver/test/firewall-domain-list.test.ts @@ -0,0 +1,123 @@ +import * as path from 'path'; +import { Template } from '@aws-cdk/assertions'; +import { Bucket } from '@aws-cdk/aws-s3'; +import { Stack } from '@aws-cdk/core'; +import { FirewallDomainList, FirewallDomains } from '../lib'; + +let stack: Stack; +beforeEach(() => { + stack = new Stack(); +}); + +test('domain list from strings', () => { + // WHEN + new FirewallDomainList(stack, 'List', { + domains: FirewallDomains.fromList(['first-domain.com', 'second-domain.net']), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Route53Resolver::FirewallDomainList', { + Domains: [ + 'first-domain.com', + 'second-domain.net', + ], + }); +}); + +test('domain list from S3 URL', () => { + // WHEN + new FirewallDomainList(stack, 'List', { + domains: FirewallDomains.fromS3Url('s3://bucket/prefix/object'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Route53Resolver::FirewallDomainList', { + DomainFileUrl: 's3://bucket/prefix/object', + }); +}); + +test('domain list from S3', () => { + // WHEN + new FirewallDomainList(stack, 'List', { + domains: FirewallDomains.fromS3(Bucket.fromBucketName(stack, 'Bucket', 'bucket'), 'prefix/object'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Route53Resolver::FirewallDomainList', { + DomainFileUrl: 's3://bucket/prefix/object', + }); +}); + +test('domain list from asset', () => { + // WHEN + new FirewallDomainList(stack, 'List', { + domains: FirewallDomains.fromAsset(path.join(__dirname, 'domains.txt')), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Route53Resolver::FirewallDomainList', { + DomainFileUrl: { + 'Fn::Join': [ + '', + [ + 's3://', + { + Ref: 'AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aS3BucketD6778673', + }, + '/', + { + 'Fn::Select': [ + 0, + { + 'Fn::Split': [ + '||', + { + Ref: 'AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aS3VersionKey1A69D23D', + }, + ], + }, + ], + }, + { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '||', + { + Ref: 'AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aS3VersionKey1A69D23D', + }, + ], + }, + ], + }, + ], + ], + }, + }); +}); + +test('throws with invalid name', () => { + expect(() => new FirewallDomainList(stack, 'List', { + name: 'Inv@lid', + domains: FirewallDomains.fromList(['domain.com']), + })).toThrow(/Invalid domain list name/); +}); + +test('throws with invalid domain', () => { + expect(() => new FirewallDomainList(stack, 'List', { + domains: FirewallDomains.fromList(['valid.fr', 'inv@lid.com']), + })).toThrow(/Invalid domain/); +}); + +test('throws with fromAsset and not .txt', () => { + expect(() => new FirewallDomainList(stack, 'List', { + domains: FirewallDomains.fromAsset('image.jpg'), + })).toThrow(/expects a file with the .txt extension/); +}); + +test('throws with invalid S3 URL', () => { + expect(() => new FirewallDomainList(stack, 'List', { + domains: FirewallDomains.fromS3Url('https://invalid/bucket/url'), + })).toThrow(/The S3 URL must start with s3:\/\//); +}); diff --git a/packages/@aws-cdk/aws-route53resolver/test/firewall-rule-group.test.ts b/packages/@aws-cdk/aws-route53resolver/test/firewall-rule-group.test.ts new file mode 100644 index 0000000000000..f8868d2bb31d9 --- /dev/null +++ b/packages/@aws-cdk/aws-route53resolver/test/firewall-rule-group.test.ts @@ -0,0 +1,137 @@ +import { Template } from '@aws-cdk/assertions'; +import { Vpc } from '@aws-cdk/aws-ec2'; +import { Duration, Stack } from '@aws-cdk/core'; +import { DnsBlockResponse, FirewallDomainList, FirewallRuleAction, FirewallRuleGroup, IFirewallDomainList } from '../lib'; + +let stack: Stack; +let firewallDomainList: IFirewallDomainList; +beforeEach(() => { + stack = new Stack(); + firewallDomainList = FirewallDomainList.fromFirewallDomainListId(stack, 'List', 'domain-list-id'); +}); + +test('basic rule group', () => { + // WHEN + new FirewallRuleGroup(stack, 'RuleGroup', { + rules: [ + { + priority: 10, + firewallDomainList, + action: FirewallRuleAction.block(), + }, + ], + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Route53Resolver::FirewallRuleGroup', { + FirewallRules: [ + { + Action: 'BLOCK', + BlockResponse: 'NODATA', + FirewallDomainListId: 'domain-list-id', + Priority: 10, + }, + ], + }); +}); + +test('use addRule to add rules', () => { + // GIVEN + const ruleGroup = new FirewallRuleGroup(stack, 'RuleGroup', { + rules: [ + { + priority: 10, + firewallDomainList, + action: FirewallRuleAction.allow(), + }, + ], + }); + + // WHEN + ruleGroup.addRule({ + priority: 20, + firewallDomainList: FirewallDomainList.fromFirewallDomainListId(stack, 'OtherList', 'other-list-id'), + action: FirewallRuleAction.allow(), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Route53Resolver::FirewallRuleGroup', { + FirewallRules: [ + { + Action: 'ALLOW', + FirewallDomainListId: 'domain-list-id', + Priority: 10, + }, + { + Action: 'ALLOW', + FirewallDomainListId: 'other-list-id', + Priority: 20, + }, + ], + }); +}); + +test('rule with response override', () => { + // GIVEN + const ruleGroup = new FirewallRuleGroup(stack, 'RuleGroup'); + + // WHEN + ruleGroup.addRule({ + priority: 10, + firewallDomainList, + action: FirewallRuleAction.block(DnsBlockResponse.override('amazon.com', Duration.minutes(5))), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Route53Resolver::FirewallRuleGroup', { + FirewallRules: [ + { + Action: 'BLOCK', + BlockOverrideDnsType: 'CNAME', + BlockOverrideDomain: 'amazon.com', + BlockOverrideTtl: 300, + BlockResponse: 'OVERRIDE', + FirewallDomainListId: 'domain-list-id', + Priority: 10, + }, + ], + }); +}); + +test('associate rule group with a vpc', () => { + // GIVEN + const vpc = new Vpc(stack, 'Vpc'); + const ruleGroup = new FirewallRuleGroup(stack, 'RuleGroup'); + + // WHEN + ruleGroup.associate('Association', { + priority: 101, + vpc, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Route53Resolver::FirewallRuleGroupAssociation', { + FirewallRuleGroupId: { + 'Fn::GetAtt': [ + 'RuleGroup06BA8844', + 'Id', + ], + }, + Priority: 101, + VpcId: { + Ref: 'Vpc8378EB38', + }, + }); +}); + +test('throws when associating with a priority not between 100-9,000', () => { + // GIVEN + const vpc = new Vpc(stack, 'Vpc'); + const ruleGroup = new FirewallRuleGroup(stack, 'RuleGroup'); + + // THEN + expect(() => ruleGroup.associate('Association', { + priority: 100, + vpc, + })).toThrow(/Priority must be greater than 100 and less than 9000/); +}); diff --git a/packages/@aws-cdk/aws-route53resolver/test/integ.firewall.expected.json b/packages/@aws-cdk/aws-route53resolver/test/integ.firewall.expected.json new file mode 100644 index 0000000000000..c6b43a9c3ccfd --- /dev/null +++ b/packages/@aws-cdk/aws-route53resolver/test/integ.firewall.expected.json @@ -0,0 +1,321 @@ +{ + "Resources": { + "Vpc8378EB38": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "cdk-route53-resolver-firewall/Vpc" + } + ] + } + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/17", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "cdk-route53-resolver-firewall/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "cdk-route53-resolver-firewall/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTableAssociation97140677": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + } + } + }, + "VpcPublicSubnet1DefaultRoute3DA9E72A": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "cdk-route53-resolver-firewall/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "cdk-route53-resolver-firewall/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/17", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "cdk-route53-resolver-firewall/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "cdk-route53-resolver-firewall/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableAssociation70C59FA6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + } + } + }, + "VpcPrivateSubnet1DefaultRouteBE02A9ED": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet1NATGateway4D7517AA" + } + } + }, + "VpcIGWD7BA715C": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "cdk-route53-resolver-firewall/Vpc" + } + ] + } + }, + "VpcVPCGWBF912B6E": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "InternetGatewayId": { + "Ref": "VpcIGWD7BA715C" + } + } + }, + "BlockListC03D0423": { + "Type": "AWS::Route53Resolver::FirewallDomainList", + "Properties": { + "Domains": [ + "bad-domain.com", + "bot-domain.net" + ] + } + }, + "OverrideListF573FB0F": { + "Type": "AWS::Route53Resolver::FirewallDomainList", + "Properties": { + "Domains": [ + "override-domain.com" + ] + } + }, + "OtherListBA4427B5": { + "Type": "AWS::Route53Resolver::FirewallDomainList", + "Properties": { + "DomainFileUrl": { + "Fn::Join": [ + "", + [ + "s3://", + { + "Ref": "AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aS3BucketD6778673" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aS3VersionKey1A69D23D" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aS3VersionKey1A69D23D" + } + ] + } + ] + } + ] + ] + } + } + }, + "RuleGroup06BA8844": { + "Type": "AWS::Route53Resolver::FirewallRuleGroup", + "Properties": { + "FirewallRules": [ + { + "Action": "BLOCK", + "BlockResponse": "NODATA", + "FirewallDomainListId": { + "Fn::GetAtt": [ + "BlockListC03D0423", + "Id" + ] + }, + "Priority": 10 + }, + { + "Action": "BLOCK", + "BlockOverrideDnsType": "CNAME", + "BlockOverrideDomain": "amazon.com", + "BlockOverrideTtl": 0, + "BlockResponse": "OVERRIDE", + "FirewallDomainListId": { + "Fn::GetAtt": [ + "OverrideListF573FB0F", + "Id" + ] + }, + "Priority": 20 + } + ] + } + }, + "RuleGroupAssociation5494BFB1": { + "Type": "AWS::Route53Resolver::FirewallRuleGroupAssociation", + "Properties": { + "FirewallRuleGroupId": { + "Fn::GetAtt": [ + "RuleGroup06BA8844", + "Id" + ] + }, + "Priority": 101, + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + } + }, + "Parameters": { + "AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aS3BucketD6778673": { + "Type": "String", + "Description": "S3 bucket for asset \"e820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35a\"" + }, + "AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aS3VersionKey1A69D23D": { + "Type": "String", + "Description": "S3 key for asset version \"e820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35a\"" + }, + "AssetParameterse820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35aArtifactHashFF61A347": { + "Type": "String", + "Description": "Artifact hash for asset \"e820b3f07bf66854be0dfd6f3ec357a10d644f2011069e5ad07d42f4f89ed35a\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53resolver/test/integ.firewall.ts b/packages/@aws-cdk/aws-route53resolver/test/integ.firewall.ts new file mode 100644 index 0000000000000..7c4b2498af6f5 --- /dev/null +++ b/packages/@aws-cdk/aws-route53resolver/test/integ.firewall.ts @@ -0,0 +1,46 @@ +import * as path from 'path'; +import { Vpc } from '@aws-cdk/aws-ec2'; +import { App, Stack, StackProps } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import * as route53resolver from '../lib'; + +class TestStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const vpc = new Vpc(this, 'Vpc', { maxAzs: 1 }); + + const blockList = new route53resolver.FirewallDomainList(this, 'BlockList', { + domains: route53resolver.FirewallDomains.fromList(['bad-domain.com', 'bot-domain.net']), + }); + const overrideList = new route53resolver.FirewallDomainList(this, 'OverrideList', { + domains: route53resolver.FirewallDomains.fromList(['override-domain.com']), + }); + + new route53resolver.FirewallDomainList(this, 'OtherList', { + domains: route53resolver.FirewallDomains.fromAsset(path.join(__dirname, 'domains.txt')), + }); + + const ruleGroup = new route53resolver.FirewallRuleGroup(this, 'RuleGroup'); + + ruleGroup.addRule({ + priority: 10, + firewallDomainList: blockList, + action: route53resolver.FirewallRuleAction.block(), + }); + ruleGroup.addRule({ + priority: 20, + firewallDomainList: overrideList, + action: route53resolver.FirewallRuleAction.block(route53resolver.DnsBlockResponse.override('amazon.com')), + }); + + ruleGroup.associate('Association', { + priority: 101, + vpc, + }); + } +} + +const app = new App(); +new TestStack(app, 'cdk-route53-resolver-firewall'); +app.synth(); diff --git a/packages/@aws-cdk/aws-route53resolver/test/route53resolver.test.ts b/packages/@aws-cdk/aws-route53resolver/test/route53resolver.test.ts deleted file mode 100644 index 465c7bdea0693..0000000000000 --- a/packages/@aws-cdk/aws-route53resolver/test/route53resolver.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assertions'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -});