diff --git a/package-lock.json b/package-lock.json index f53b5eab..e7f40d92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,10 @@ "resolved": "packages/static-hosting", "link": true }, + "node_modules/@aligent/cdk-waf": { + "resolved": "packages/waf", + "link": true + }, "node_modules/@aligent/esbuild": { "resolved": "packages/esbuild", "link": true @@ -5903,6 +5907,7 @@ } }, "packages/shared-vpc": { + "name": "@aligent/cdk-shared-vpc", "version": "2.0.0", "dependencies": { "aws-cdk-lib": "2.97.0", @@ -5939,6 +5944,25 @@ "ts-node": "^10.9.1", "typescript": "~5.2.2" } + }, + "packages/waf": { + "name": "@aligent/cdk-waf", + "version": "2.0.0", + "license": "GPL-3.0-only", + "dependencies": { + "aws-cdk-lib": "2.97.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" + }, + "devDependencies": { + "@types/jest": "^29.5.5", + "@types/node": "20.6.3", + "aws-cdk": "2.97.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "typescript": "~5.2.2" + } } } } diff --git a/packages/waf/.gitignore b/packages/waf/.gitignore new file mode 100644 index 00000000..8f77f768 --- /dev/null +++ b/packages/waf/.gitignore @@ -0,0 +1,58 @@ +# These are some examples of commonly ignored file patterns. +# You should customize this list as applicable to your project. +# Learn more about .gitignore: +# https://www.atlassian.com/git/tutorials/saving-changes/gitignore + +# Node artifact files +node_modules/ +dist/ + +# Compiled Java class files +*.class + +# Compiled Python bytecode +*.py[cod] + +# Log files +*.log + +# Package files +*.jar + +# Maven +target/ +dist/ + +# JetBrains IDE +.idea/ + +# Unit test reports +TEST*.xml + +# Generated by MacOS +.DS_Store + +# Generated by Windows +Thumbs.db + +# Applications +*.app +*.exe +*.war + +# Large media files +*.mp4 +*.tiff +*.avi +*.flv +*.mov +*.wmv + +!jest.config.js + +# CDK asset staging directory +.cdk.staging +cdk.out + +*.d.ts +*.js diff --git a/packages/waf/.npmignore b/packages/waf/.npmignore new file mode 100644 index 00000000..bfd115ba --- /dev/null +++ b/packages/waf/.npmignore @@ -0,0 +1,11 @@ +*.ts +!lib/handlers/*.ts +!*.d.ts +!*.js + +# CDK asset staging directory +.cdk.staging +cdk.out + +# Samples +sample/ diff --git a/packages/waf/.npmrc b/packages/waf/.npmrc new file mode 100644 index 00000000..3b9bddfc --- /dev/null +++ b/packages/waf/.npmrc @@ -0,0 +1 @@ +10.1.0 \ No newline at end of file diff --git a/packages/waf/.nvmrc b/packages/waf/.nvmrc new file mode 100644 index 00000000..ef1520fc --- /dev/null +++ b/packages/waf/.nvmrc @@ -0,0 +1 @@ +20.7.0 \ No newline at end of file diff --git a/packages/waf/CdkPipelineCrossAccountDeploy.jpeg b/packages/waf/CdkPipelineCrossAccountDeploy.jpeg new file mode 100644 index 00000000..30550aab Binary files /dev/null and b/packages/waf/CdkPipelineCrossAccountDeploy.jpeg differ diff --git a/packages/waf/README.md b/packages/waf/README.md new file mode 100644 index 00000000..83442075 --- /dev/null +++ b/packages/waf/README.md @@ -0,0 +1,67 @@ +# Aligent AWS WAF + +## Overview + +This repository defines a CDK construct for provisioning an AWS Web Application Firewall (WAF) stack. It can be imported and used within CDK application. +##Example +The following CDK snippet can be used to provision the an AWS WAF stack. + +``` +import 'source-map-support/register'; +const cdk = require('@aws-cdk/core'); +import { WebApplicationFirewall } from '@aligent/cdk-waf'; +import { Stack } from '@aws-cdk/core'; + + +import { Environment } from '@aws-cdk/core' +import { env } from 'node:process'; + +const preprodEnv: Environment = {account: '', region: ''}; + +const target = ''; +const appName = 'WAF'; + +const defaultAllowedIPv4s = [ + 'a.a.a.a/32', 'b.b.b.b/32', // Offices + 'c.c.c.c/32', 'd.d.d.d/32', // Payment Gateways +] + +const defaultAllowedIPv6s = [ + '1234:abcd:5678:ef01::/56', // Offices + '1234:ef01:5678:abcd::/56', // Security Scanner +] + +export const preProductionWafStackProps = { +env: preprodEnv, + activate: true, // Update this line with either true or false, defining Block mode or Count-only mode, respectively. + allowedIPs: defaultAllowedIPs.concat([ + 'y.y.y.y/32' // AWS NAT GW of preprod vpc + // environment-specific comma-separated allow-list comes here + ]), + allowedUserAgents: [], // Allowed User-Agent list that would have been blocked by AWS BadBot rule. Case-sensitive. Optional. + excludedAwsRules: [], // The rule to exclude (override) from AWS-managed RuleSet. Optional. + associatedLoadBalancerArn: '', + wafName: +} + +class WAFStack extends Stack { + constructor(scope: Construct, id: string, props: preprodEnv) { + super(scope, id, props); + + new WebApplicationFirewall(scope, 'waf-stack', prod); + } +} + +new WAFStack(scope, envName, preProductionWafStackProps); +``` + +## Monitor and activate +By default, WebACL this stack creates will work in COUNT mode to begin with.After a certain period of monitoring under real traffic and load, apply necessary changes, e.g. IP allow_list or rate limit, to avoid service interruptions before switching to BLOCK mode. + +## Local development +[NPM link](https://docs.npmjs.com/cli/v7/commands/npm-link) can be used to develop the module locally. +1. Pull this repository locally +2. `cd` into this repository +3. run `npm link` +4. `cd` into the downstream repo (target project, etc) and run `npm link '@aligent/cdk-waf'` +The downstream repository should now include a symlink to this module. Allowing local changes to be tested before pushing. You may want to update the version notation of the package in the downstream repository's `package.json`. \ No newline at end of file diff --git a/packages/waf/index.ts b/packages/waf/index.ts new file mode 100644 index 00000000..a28fd36e --- /dev/null +++ b/packages/waf/index.ts @@ -0,0 +1,3 @@ +import { WebApplicationFirewall, WebApplicationFirewallProps } from "./lib/waf"; + +export { WebApplicationFirewall, WebApplicationFirewallProps }; diff --git a/packages/waf/lib/waf.ts b/packages/waf/lib/waf.ts new file mode 100644 index 00000000..c4d92bf1 --- /dev/null +++ b/packages/waf/lib/waf.ts @@ -0,0 +1,372 @@ +import { aws_wafv2 } from "aws-cdk-lib"; +import { Construct } from "constructs"; + +export const REGIONAL = "REGIONAL"; +export type REGIONAL = typeof REGIONAL; + +export const CLOUDFRONT = "CLOUDFRONT"; +export type CLOUDFRONT = typeof CLOUDFRONT; + +export interface WebApplicationFirewallProps { + /** + * Whether this WAF is global or regional + */ + scope?: REGIONAL | CLOUDFRONT; + + /** + * true for blocking mode, false for Count-only mode + */ + activate?: boolean; + + /** + * List of Allowed IPv4 addresses, if neither allowedIPs nor allowedIPsIPv6 are set allow_xff_ip_rule and allow_src_ip_rule rules + * are not added + */ + allowedIPs?: string[]; + + /** + * List of Allowed IPv6 addresses, if neither allowedIPs nor allowedIPsIPv6 are set allow_xff_ip_rule and allow_src_ip_rule rules + * are not added + */ + allowedIPv6s?: string[]; + + /** + * Explicit paths to allow through the waf + */ + allowedPaths?: string[]; + + /** + * Default Rate limit count, if not set the rate limit rule will not be added + */ + rateLimit?: number; + + /** + * Explicit allow of user agents, if not set rule will not be added + */ + allowedUserAgents?: string[]; + + /** + * A list of AWS Rules to ignore + */ + excludedAwsRules?: string[]; + + /** + * A list of ARNs to associate with the WAF + */ + associations?: string[]; + + /** + * Name of the WAF + */ + wafName: string; + + /** + * Whether to block by default + */ + blockByDefault?: boolean; +} + +export class WebApplicationFirewall extends Construct { + readonly web_acl: aws_wafv2.CfnWebACL; + + constructor( + scope: Construct, + id: string, + props: WebApplicationFirewallProps + ) { + super(scope, id); + + const finalRules: aws_wafv2.CfnWebACL.RuleProperty[] = []; + const wafScope = props.scope ?? REGIONAL; + + if (props.allowedIPs) { + // IPv4 Allowlist + const allowed_ips = new aws_wafv2.CfnIPSet(this, "IPSet-IPv4", { + addresses: props.allowedIPs, + ipAddressVersion: "IPV4", + scope: wafScope, + description: props.wafName, + }); + + finalRules.push({ + name: "allow_xff_ip_rule", + priority: 2, + statement: { + ipSetReferenceStatement: { + arn: allowed_ips.attrArn, + ipSetForwardedIpConfig: { + fallbackBehavior: "NO_MATCH", + headerName: "X-Forwarded-For", + position: "ANY", + }, + }, + }, + action: { allow: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "AllowXFFIPRule", + sampledRequestsEnabled: true, + }, + }); + + finalRules.push({ + name: "allow_src_ip_rule", + priority: 3, + statement: { + ipSetReferenceStatement: { + arn: allowed_ips.attrArn, + }, + }, + action: { allow: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "allow_src_ip_rule", + sampledRequestsEnabled: true, + }, + }); + } + + if (props.allowedIPv6s) { + // IPv6 Allowlist + const allowed_ips = new aws_wafv2.CfnIPSet(this, "IPSet-IPv6", { + addresses: props.allowedIPv6s, + ipAddressVersion: "IPV6", + scope: wafScope, + description: props.wafName, + }); + + finalRules.push({ + name: "allow_xff_ip_rule_ipv6", + priority: 4, + statement: { + ipSetReferenceStatement: { + arn: allowed_ips.attrArn, + ipSetForwardedIpConfig: { + fallbackBehavior: "NO_MATCH", + headerName: "X-Forwarded-For", + position: "ANY", + }, + }, + }, + action: { allow: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "AllowXFFIPRule", + sampledRequestsEnabled: true, + }, + }); + + finalRules.push({ + name: "allow_src_ip_rule_ipv6", + priority: 5, + statement: { + ipSetReferenceStatement: { + arn: allowed_ips.attrArn, + }, + }, + action: { allow: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "allow_src_ip_rule", + sampledRequestsEnabled: true, + }, + }); + } + + // Implement AWSManagedRulesKnownBadInputsRuleSet + finalRules.push({ + name: "bad_actors_rule", + priority: 0, + overrideAction: { none: {} }, + statement: { + managedRuleGroupStatement: { + name: "AWSManagedRulesKnownBadInputsRuleSet", + vendorName: "AWS", + excludedRules: [ + { name: "Host_localhost_HEADER" }, + { name: "PROPFIND_METHOD" }, + { name: "ExploitablePaths_URIPATH" }, + ], + }, + }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "bad_actors_rule", + sampledRequestsEnabled: true, + }, + }); + + if (props.allowedPaths) { + // Path Allowlist + const allowed_paths = new aws_wafv2.CfnRegexPatternSet(this, "PathSet", { + regularExpressionList: props.allowedPaths, + scope: wafScope, + }); + + finalRules.push({ + name: "allow_path_rule", + priority: 1, + statement: { + regexPatternSetReferenceStatement: { + arn: allowed_paths.attrArn, + fieldToMatch: { + uriPath: {}, + }, + textTransformations: [ + { + priority: 0, + type: "NONE", + }, + ], + }, + }, + action: { allow: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "AllowPathRule", + sampledRequestsEnabled: true, + }, + }); + } + + // UserAgent Allowlist - only when the parameter is present + if (props.allowedUserAgents) { + const allowed_user_agent = new aws_wafv2.CfnRegexPatternSet( + this, + "UserAgent", + { + regularExpressionList: props.allowedUserAgents, + scope: wafScope, + } + ); + + finalRules.push({ + name: "allow_user_agent_rule", + priority: 6, + statement: { + regexPatternSetReferenceStatement: { + arn: allowed_user_agent.attrArn, + fieldToMatch: { singleHeader: { Name: "User-Agent" } }, + textTransformations: [ + { + priority: 0, + type: "NONE", + }, + ], + }, + }, + action: { allow: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "allow_user_agent_rule", + sampledRequestsEnabled: true, + }, + }); + } + + // Activate the rules or not + let overrideAction: object = { count: {} }; + let action: object = { count: {} }; + if (props.activate == true) { + overrideAction = { none: {} }; + action = { block: {} }; + } + + // Exclude specific rules from AWS Core Rule Group - only when the parameter is present + const excludedAwsRules: aws_wafv2.CfnWebACL.ExcludedRuleProperty[] = []; + if (props.excludedAwsRules) { + props.excludedAwsRules.forEach(ruleName => { + excludedAwsRules.push({ + name: ruleName, + }); + }); + } + + // Implement AWSManagedRulesCommonRuleSet + finalRules.push({ + name: "common_rule_set", + priority: 10, + statement: { + managedRuleGroupStatement: { + name: "AWSManagedRulesCommonRuleSet", + vendorName: "AWS", + excludedRules: excludedAwsRules, + }, + }, + overrideAction: overrideAction, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "common_rule_set", + sampledRequestsEnabled: true, + }, + }); + + // Implement AWSManagedRulesPHPRuleSet + finalRules.push({ + name: "php_rule_set", + priority: 11, + statement: { + managedRuleGroupStatement: { + name: "AWSManagedRulesPHPRuleSet", + vendorName: "AWS", + }, + }, + overrideAction: overrideAction, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "php_rule_set", + sampledRequestsEnabled: true, + }, + }); + + // Implement rate-based limit + if (props.rateLimit) { + finalRules.push({ + name: "rate_limit_rule", + priority: 20, + statement: { + rateBasedStatement: { + aggregateKeyType: "FORWARDED_IP", + forwardedIpConfig: { + fallbackBehavior: "MATCH", + headerName: "X-Forwarded-For", + }, + limit: props.rateLimit, + }, + }, + action: action, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "rate_limit_rule", + sampledRequestsEnabled: true, + }, + }); + } + + const defaultAction = props.blockByDefault ? { block: {} } : { allow: {} }; + + this.web_acl = new aws_wafv2.CfnWebACL(this, "WebAcl", { + name: props.wafName, + defaultAction: defaultAction, + scope: wafScope, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "WebAcl", + sampledRequestsEnabled: true, + }, + rules: finalRules, + }); + + // If any resources associations have been passed loop through them and add an association with WebACL + if (props.associations) { + props.associations.forEach((association, index) => { + new aws_wafv2.CfnWebACLAssociation(this, "WebACLAssociation" + index, { + // If the application stack has had the ARN exported, importValue could be used as below: + // resourceArn: cdk.Fn.importValue("WAFTestALB"), + resourceArn: association, + webAclArn: this.web_acl.attrArn, + }); + }); + } + } +} diff --git a/packages/waf/package.json b/packages/waf/package.json new file mode 100644 index 00000000..60f54db3 --- /dev/null +++ b/packages/waf/package.json @@ -0,0 +1,31 @@ +{ + "name": "@aligent/cdk-waf", + "version": "2.0.0", + "main": "index.js", + "license": "GPL-3.0-only", + "homepage": "https://github.com/aligent/aws-cdk-waf-stack#readme", + "repository": { + "type": "git", + "url": "https://github.com/aligent/aws-cdk-waf-stack" + }, + "types": "index.d.ts", + "scripts": { + "build": "tsc", + "prepublish": "tsc" + }, + "devDependencies": { + "@types/jest": "^29.5.5", + "@types/node": "20.6.3", + "aws-cdk": "2.97.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "typescript": "~5.2.2" + }, + "dependencies": { + "aws-cdk-lib": "2.97.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" + } + } + \ No newline at end of file diff --git a/packages/waf/tsconfig.json b/packages/waf/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/waf/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +}