├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── index.js └── index.spec.js ├── package-lock.json └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true ; Top-most EditorConfig file 2 | 3 | ; Unix-style newlines with a newline ending every file 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | .serverless/ 4 | 5 | *.tgz 6 | .vscode 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | test/ 3 | 4 | .editorconfig 5 | .travis.yml 6 | 7 | *.tgz 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "lts/*" 5 | cache: npm 6 | jobs: 7 | include: 8 | - stage: install 9 | name: "Install Dependencies" 10 | script: npm install 11 | - stage: test 12 | name: "Unit Tests + Coverage" 13 | script: 14 | - npm test 15 | - echo "Report Coverage..." 16 | - npm run coveralls 17 | - stage: publish 18 | name: "Publish to NPM" 19 | node_js: 'node' 20 | script: echo "Publishing to NPM..." 21 | deploy: 22 | provider: npm 23 | email: contact@michael-souza.com 24 | api_key: 25 | secure: UrwsyB+nJpAs3jGwSdtngOIE58Q+eL+MS74CiclymeHB1Rwcng42PqhMh/Prqr0xXu7Tao/vJmfRfHDGTFU3PGmbu/qt08DmLzKRbwedRtJLE9mUStLZ3fP3yHpAnZsmthsY1Q/cNl6142wzYEDlIw780ywtzoXdLqKOQgMZyaqqCdziX7qGwUBUC2pazFXiPJ69CEd66YeX7X7dBhXhFgpgVinlEzjBCo+dWMzAB9HyZBljAtk5TKkvtDhYUQ+Z5rWqnH1M+VrxsIPhgQuxl0ff5i7I6MNb+4Ejx5z1voEvFGBAIhhT0MYe/PqT/wgnpNhJ8VEV1dW59dXeiZBwxqfka1jVFy9h+cvuCJwT1lMOjqmdk/RRsuxKBP9YWi8UrAUpIrHBH0/VoOKTO4SyeOXZTbcVgD5MDkx+sT6bBrzA0aw5zKwaj82KUMbD0jL6x23HFwl65WgMRhXLTKdZfU0hTiMq2zeGTfwYtMeHdFiAdlXRXqfQYB8cSB/kQypf5dBRb6kSdr0Q11pos25atlacSkNdXdhNQNZuF4P/uaPts2uOI2xukRC75/MaZVqgKXZD8WzbK+KvCG8dcFwn3o125ogaafvHQ2/YiKb/AH0JjM0Ih35i7sLwjW5zMTl993CedRbOFH/kfo6Za3J49rzQJ8mfVNJEP+/PlX8/ngA= 26 | on: 27 | branch: master 28 | tags: true 29 | condition: -z $SKIP_PUBLISH 30 | stages: 31 | - install 32 | - test 33 | - publish 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mike Souza 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-associate-waf 2 | 3 | [![NPM Downloads](https://img.shields.io/npm/dt/serverless-associate-waf)](https://www.npmjs.com/package/serverless-associate-waf) [![Build Status](https://travis-ci.org/MikeSouza/serverless-associate-waf.svg?branch=master)](https://travis-ci.org/MikeSouza/serverless-associate-waf) 4 | [![Coverage Status](https://coveralls.io/repos/github/MikeSouza/serverless-associate-waf/badge.svg?branch=master)](https://coveralls.io/github/MikeSouza/serverless-associate-waf?branch=master) 5 | 6 | Associate a regional WAF with the AWS API Gateway used by your Serverless stack. 7 | 8 | ## Install 9 | 10 | `npm install serverless-associate-waf --save-dev` 11 | 12 | ## Configuration 13 | 14 | Add the plugin to your `serverless.yml`: 15 | 16 | ```yaml 17 | plugins: 18 | - serverless-associate-waf 19 | ``` 20 | 21 | ### Associating a Regional WAF with the API Gateway 22 | 23 | Add your custom configuration: 24 | 25 | ```yaml 26 | custom: 27 | associateWaf: 28 | name: myRegionalWaf 29 | version: Regional #(optional) Regional | V2 30 | ``` 31 | 32 | | Property | Required | Type | Default | Description | 33 | |----------|----------|----------|---------|----------------------------------------------------------------| 34 | | `name` | `true` | `string` | | The name of the regional WAF to associate the API Gateway with | 35 | | `version`| `false` | `string` | `Regional`| The AWS WAF version to be used| 36 | 37 | ### Disassociating a Regional WAF from the API Gateway 38 | 39 | Remove the `name` property from your custom configuration but keep the `version` if specified, and then deploy the application. The plugin must stay in the plugins list of `serverless.yml` in order for the WAF to be disassociated. 40 | 41 | ## Usage 42 | 43 | Configuration of your `serverless.yml` is all you need. 44 | 45 | There are no custom commands, just run: `sls deploy` 46 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const chalk = require('chalk') 4 | const REST_API_ID_KEY = 'ApiGatewayRestApiWaf'; 5 | const DEFAULT_WAF_VERSION = "WAFRegional" 6 | const DEFAULT_WAF_SCOPE = "REGIONAL" 7 | 8 | const get = (obj, path, defaultValue) => { 9 | return path.split('.').filter(Boolean).every(step => !(step && !(obj = obj[step]))) ? obj : defaultValue 10 | } 11 | 12 | class AssociateWafPlugin { 13 | constructor(serverless, options) { 14 | this.serverless = serverless 15 | this.provider = this.serverless.providers.aws 16 | 17 | this.config = get(this.serverless.service, 'custom.associateWaf', {}) 18 | 19 | this.wafVersion = `WAF${this.config.version || "Regional"}` //config.version can be one of [V2, Regional] 20 | this.wafScope = DEFAULT_WAF_SCOPE //WAFV2 requires a scope setting 21 | this.verifyValidWafConfig() 22 | this.hooks = {} 23 | 24 | this.hooks['after:deploy:deploy'] = this.updateWafAssociation.bind(this) 25 | this.hooks['before:package:finalize'] = this.updateCloudFormationTemplate.bind(this) 26 | } 27 | 28 | verifyValidWafConfig() { 29 | const validVersions = [DEFAULT_WAF_VERSION, "WAFV2"] //allowed WAF versions 30 | if (!validVersions.includes(this.wafVersion)) { 31 | this.wafVersion = DEFAULT_WAF_VERSION 32 | this.serverless.cli.log(`\n-------- Invalid WAF Version Configuration --------\nVersion Defaulted to ${this.wafVersion}`) 33 | } 34 | } 35 | 36 | defaultStackName() { 37 | return `${this.serverless.service.getServiceName()}-${this.provider.getStage()}` 38 | } 39 | 40 | getApiGatewayStageArn(restApiId) { 41 | return `arn:aws:apigateway:${this.provider.getRegion()}::/restapis/${restApiId}/stages/${this.provider.getStage()}` 42 | } 43 | 44 | updateCloudFormationTemplate() { 45 | this.outputRestApiId() 46 | } 47 | 48 | outputRestApiId() { 49 | const autoGeneratedRestApiId = { Ref: 'ApiGatewayRestApi' }; 50 | 51 | this.serverless.service.provider.compiledCloudFormationTemplate.Outputs[REST_API_ID_KEY] = { 52 | Description: 'Rest API Id', 53 | Value: autoGeneratedRestApiId, 54 | }; 55 | }; 56 | 57 | async updateWafAssociation() { 58 | if ((this.config) && (this.config.name) && (this.config.name.trim().length != 0)){ 59 | await this.associateWaf(); 60 | } else { 61 | await this.disassociateWaf(); 62 | } 63 | } 64 | 65 | async findWebAclByName(name) { 66 | let params = { Limit: 100 } 67 | if (this.wafVersion !== DEFAULT_WAF_VERSION) { //WAFV2 requires Scope variable 68 | params.Scope = this.wafScope 69 | } 70 | 71 | const response = await this.provider.request(this.wafVersion, 'listWebACLs', params) 72 | if (response.WebACLs) { 73 | for (let webAcl of response.WebACLs) { 74 | if (name === webAcl.Name) { 75 | return this.wafVersion === DEFAULT_WAF_VERSION ? webAcl.WebACLId : webAcl.ARN //WAFV2 uses WebACLArn instead of WebACLId 76 | } 77 | } 78 | } 79 | } 80 | 81 | async findStackResourceByLogicalId(stackName, logicalId) { 82 | const response = await this.provider.request('CloudFormation', 'listStackResources', { StackName: stackName }) 83 | if (response.StackResourceSummaries) { 84 | for (let resourceSummary of response.StackResourceSummaries) { 85 | if (logicalId === resourceSummary.LogicalResourceId) { 86 | return resourceSummary 87 | } 88 | } 89 | } 90 | } 91 | 92 | async findStackOutputByLogicalId(stackName, logicalId) { 93 | const response = await this.provider.request('CloudFormation', 'describeStacks', { StackName: stackName }) 94 | if(response.Stacks) { 95 | if (response.Stacks[0].Outputs) { 96 | for (let resourceSummary of response.Stacks[0].Outputs) { 97 | if (logicalId === resourceSummary.OutputKey) { 98 | return resourceSummary 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | async getRestApiId() { 106 | const apiGateway = this.serverless.service.provider.apiGateway 107 | if (apiGateway && apiGateway.restApiId) { 108 | return apiGateway.restApiId 109 | } 110 | 111 | const stackName = this.serverless.service.provider.stackName || this.defaultStackName(); 112 | 113 | const stackResource = await this.findStackResourceByLogicalId(stackName, 'ApiGatewayRestApi') 114 | if (!stackResource) { 115 | this.serverless.cli.log(`RestApiId not found (split stacks plugin used?), using stack outputs for RestApiId.`); 116 | const stackOutput = await this.findStackOutputByLogicalId(stackName, REST_API_ID_KEY) 117 | if (stackOutput && stackOutput.OutputValue) { 118 | return stackOutput.OutputValue 119 | } 120 | } 121 | 122 | if (stackResource && stackResource.PhysicalResourceId) { 123 | return stackResource.PhysicalResourceId 124 | } 125 | } 126 | 127 | async associateWaf() { 128 | try { 129 | const restApiId = await this.getRestApiId() 130 | if (!restApiId) { 131 | this.serverless.cli.log('Unable to determine REST API ID') 132 | return 133 | } 134 | 135 | const webAclId = await this.findWebAclByName(this.config.name) 136 | if (!webAclId) { 137 | this.serverless.cli.log(`Unable to find WAF named '${this.config.name}'`) 138 | return 139 | } 140 | 141 | const params = this.wafVersion === DEFAULT_WAF_VERSION ? 142 | { 143 | ResourceArn: this.getApiGatewayStageArn(restApiId), //used for WAFRegional 144 | WebACLId: webAclId 145 | } 146 | : 147 | { 148 | ResourceArn: this.getApiGatewayStageArn(restApiId), //used for WAFV2 149 | WebACLArn: webAclId 150 | } 151 | 152 | this.serverless.cli.log('Associating WAF...') 153 | await this.provider.request(this.wafVersion, 'associateWebACL', params) 154 | } catch (e) { 155 | console.error(chalk.red(`\n-------- Associate WAF Error --------\n${e.message}`)) 156 | } 157 | } 158 | 159 | async disassociateWaf() { 160 | try { 161 | const restApiId = await this.getRestApiId() 162 | if (!restApiId) { 163 | this.serverless.cli.log('Unable to determine REST API ID') 164 | return 165 | } 166 | 167 | const params = { 168 | ResourceArn: this.getApiGatewayStageArn(restApiId) 169 | } 170 | 171 | const webACLForResource = await this.provider.request(this.wafVersion, 'getWebACLForResource', params) 172 | if (webACLForResource.WebACLSummary || webACLForResource.WebACL) { //WAFV2 uses WebACL 173 | this.serverless.cli.log('Disassociating WAF...') 174 | await this.provider.request(this.wafVersion, 'disassociateWebACL', params) 175 | } 176 | 177 | } catch (e) { 178 | console.error(chalk.red(`\n-------- Disassociate WAF Error --------\n${e.message}`)) 179 | } 180 | } 181 | } 182 | 183 | module.exports = AssociateWafPlugin 184 | -------------------------------------------------------------------------------- /lib/index.spec.js: -------------------------------------------------------------------------------- 1 | const AssociateWafPlugin = require('.') 2 | const Serverless = require('serverless/lib/Serverless') 3 | const AwsProvider = jest.genMockFromModule('serverless/lib/plugins/aws/provider/awsProvider') 4 | const CLI = jest.genMockFromModule('serverless/lib/classes/CLI') 5 | 6 | const AWSErrorMessage = 'Some AWS provider error' 7 | const WAF_REGIONAL = "WAFRegional" 8 | const WAF_V2 = "WAFV2" 9 | const ALLOWED_WAF_VERSIONS = [WAF_REGIONAL, WAF_V2] 10 | const ALLOWED_WAF_SCOPE = "REGIONAL" 11 | 12 | describe('AssociateWafPlugin', () => { 13 | let plugin 14 | let serverless 15 | let options 16 | 17 | beforeEach(() => { 18 | serverless = new Serverless() 19 | serverless.service.service = 'my-service' 20 | serverless.service.serviceObject = { 21 | name: 'my-service' 22 | } 23 | options = {} 24 | serverless.setProvider('aws', new AwsProvider(serverless)) 25 | serverless.cli = new CLI(serverless) 26 | }) 27 | 28 | describe('constructor', () => { 29 | beforeEach(() => { 30 | plugin = new AssociateWafPlugin(serverless, options) 31 | }) 32 | 33 | it('should set the provider to instance of AwsProvider', () => { 34 | expect(plugin.provider).toBeInstanceOf(AwsProvider) 35 | }) 36 | 37 | it('should have access to the serverless instance', () => { 38 | expect(plugin.serverless).toEqual(serverless) 39 | }) 40 | 41 | it(`wafVersion should be one of ${ALLOWED_WAF_VERSIONS}`, () => { 42 | expect(ALLOWED_WAF_VERSIONS).toContain(plugin.wafVersion); 43 | }) 44 | 45 | it(`wafScope should equal ${ALLOWED_WAF_SCOPE}`, () => { 46 | expect(plugin.wafScope).toEqual(ALLOWED_WAF_SCOPE) 47 | }) 48 | }) 49 | 50 | describe('without configuration', () => { 51 | describe('with missing object "custom"', () => { 52 | beforeEach(() => { 53 | serverless.service.custom = undefined 54 | plugin = new AssociateWafPlugin(serverless, options) 55 | }) 56 | 57 | it('should default to empty config', () => { 58 | expect(plugin.config).toEqual({}) 59 | }) 60 | 61 | it('should set hooks', () => { 62 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 63 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 64 | }) 65 | 66 | it('"outputRestApiId()" should be invoked', async () => { 67 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 68 | }) 69 | 70 | it('"disassociateWaf()" should be invoked', async () => { 71 | await expectDisassociateWafToHaveBeenCalled(plugin) 72 | }) 73 | 74 | }) 75 | 76 | describe('with missing object "custom.associateWaf"', () => { 77 | beforeEach(() => { 78 | serverless.service.custom = {} 79 | plugin = new AssociateWafPlugin(serverless, options) 80 | }) 81 | 82 | it('should default to empty config', () => { 83 | expect(plugin.config).toEqual({}) 84 | }) 85 | 86 | it('should set hooks', () => { 87 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 88 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 89 | }) 90 | 91 | it('"outputRestApiId()" should be invoked', async () => { 92 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 93 | }) 94 | 95 | it('"disassociateWaf()" should be invoked', async () => { 96 | await expectDisassociateWafToHaveBeenCalled(plugin) 97 | }) 98 | 99 | }) 100 | 101 | describe('with null object "custom.associateWaf"', () => { 102 | beforeEach(() => { 103 | serverless.service.custom = { 104 | associateWaf: null 105 | } 106 | plugin = new AssociateWafPlugin(serverless, options) 107 | }) 108 | 109 | it('should default to empty config', () => { 110 | expect(plugin.config).toEqual({}) 111 | }) 112 | 113 | it('should set hooks', () => { 114 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 115 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 116 | }) 117 | 118 | it('"outputRestApiId()" should be invoked', async () => { 119 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 120 | }) 121 | 122 | it('"disassociateWaf()" should be invoked', async () => { 123 | await expectDisassociateWafToHaveBeenCalled(plugin) 124 | }) 125 | 126 | }) 127 | 128 | describe('with missing property "custom.associateWaf.name"', () => { 129 | beforeEach(() => { 130 | serverless.service.custom = { 131 | associateWaf: {} 132 | } 133 | plugin = new AssociateWafPlugin(serverless, options) 134 | }) 135 | 136 | it('should set hooks if ', () => { 137 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 138 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 139 | }) 140 | 141 | it('"outputRestApiId()" should be invoked', async () => { 142 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 143 | }) 144 | 145 | it('"disassociateWaf()" should be invoked', async () => { 146 | await expectDisassociateWafToHaveBeenCalled(plugin) 147 | }) 148 | }) 149 | 150 | describe('with empty property "custom.associateWaf.name"', () => { 151 | beforeEach(() => { 152 | serverless.service.custom = { 153 | associateWaf: { 154 | name: '' 155 | } 156 | } 157 | serverless.service.provider.compiledCloudFormationTemplate = { 158 | Outputs: [ 159 | {}] 160 | } 161 | plugin = new AssociateWafPlugin(serverless, options) 162 | }) 163 | 164 | it('should set hooks if ', () => { 165 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 166 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 167 | }) 168 | 169 | it('"outputRestApiId()" should be invoked', async () => { 170 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 171 | }) 172 | 173 | it('"disassociateWaf()" should be invoked', async () => { 174 | await expectDisassociateWafToHaveBeenCalled(plugin) 175 | }) 176 | }) 177 | 178 | describe('with invalid property "custom.associateWaf.version"', () => { 179 | beforeEach(() => { 180 | serverless.service.custom = { 181 | associateWaf: { 182 | version: "invalid-version" 183 | } 184 | } 185 | plugin = new AssociateWafPlugin(serverless, options) 186 | }) 187 | 188 | it('should set hooks if ', () => { 189 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 190 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 191 | }) 192 | 193 | it('should log info when invalid version configuration exists ', () => { 194 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Invalid WAF Version Configuration')) 195 | }) 196 | 197 | it(`wafVersion should equal ${WAF_REGIONAL}`, () => { 198 | expect(WAF_REGIONAL).toEqual(plugin.wafVersion); 199 | }) 200 | 201 | it('"outputRestApiId()" should be invoked', async () => { 202 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 203 | }) 204 | 205 | it('"disassociateWaf()" should be invoked', async () => { 206 | await expectDisassociateWafToHaveBeenCalled(plugin) 207 | }) 208 | }) 209 | 210 | describe('with null property "custom.associateWaf.version"', () => { 211 | beforeEach(() => { 212 | serverless.service.custom = { 213 | associateWaf: {} 214 | } 215 | serverless.service.provider.compiledCloudFormationTemplate = { 216 | Outputs: [ 217 | {}] 218 | } 219 | plugin = new AssociateWafPlugin(serverless, options) 220 | }) 221 | 222 | it('should set hooks if ', () => { 223 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 224 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 225 | }) 226 | 227 | it(`wafVersion should equal ${WAF_REGIONAL}`, () => { 228 | expect(WAF_REGIONAL).toEqual(plugin.wafVersion); 229 | }) 230 | 231 | it('"outputRestApiId()" should be invoked', async () => { 232 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 233 | }) 234 | 235 | it('"disassociateWaf()" should be invoked', async () => { 236 | await expectDisassociateWafToHaveBeenCalled(plugin) 237 | }) 238 | }) 239 | }) 240 | 241 | describe('with configuration', () => { 242 | describe('default configuration', () => { 243 | beforeEach(() => { 244 | serverless.service.custom = { 245 | associateWaf: { 246 | name: 'stage-service-name' 247 | } 248 | } 249 | serverless.service.provider.compiledCloudFormationTemplate = { 250 | Outputs: [ 251 | 'ApiGatewayRestApiWaf', {Description: 'Rest API Id', Value: 'some-api-rest-id'}] 252 | } 253 | plugin = new AssociateWafPlugin(serverless, options) 254 | }) 255 | 256 | it('should set config', () => { 257 | expect(plugin.config).toBeTruthy() 258 | }) 259 | 260 | it('should set hooks', () => { 261 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 262 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 263 | }) 264 | 265 | it('"outputRestApiId()" should be invoked', async () => { 266 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 267 | }) 268 | 269 | it('should output restApiId', () => { 270 | plugin.updateCloudFormationTemplate() 271 | expect(serverless.service.provider.compiledCloudFormationTemplate.Outputs['ApiGatewayRestApiWaf']).toBeDefined() 272 | }) 273 | 274 | it('"associateWaf()" should be invoked', async () => { 275 | plugin.associateWaf = jest.fn() 276 | plugin.associateWaf.mockResolvedValueOnce({}) 277 | await plugin.updateWafAssociation() 278 | expect(plugin.associateWaf).toHaveBeenCalled() 279 | }) 280 | }) 281 | 282 | describe('with adding property "custom.associateWaf.version" === "V2" ', () => { 283 | beforeEach(() => { 284 | serverless.service.custom = { 285 | associateWaf: { 286 | name: 'stage-service-name', 287 | version: "V2" 288 | } 289 | } 290 | serverless.service.provider.compiledCloudFormationTemplate = { 291 | Outputs: ['ApiGatewayRestApiWaf', { Description: 'Rest API Id', Value: 'some-api-rest-id' }] 292 | } 293 | plugin = new AssociateWafPlugin(serverless, options) 294 | }) 295 | 296 | it('should set config', () => { 297 | expect(plugin.config).toBeTruthy() 298 | }) 299 | 300 | it(`wafVersion should be ${WAF_V2}`, () => { 301 | expect(plugin.wafVersion).toEqual(WAF_V2); 302 | }) 303 | 304 | it('should set hooks', () => { 305 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 306 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 307 | }) 308 | 309 | it('"outputRestApiId()" should be invoked', async () => { 310 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 311 | }) 312 | 313 | it('should output restApiId', () => { 314 | plugin.updateCloudFormationTemplate() 315 | expect(serverless.service.provider.compiledCloudFormationTemplate.Outputs['ApiGatewayRestApiWaf']).toBeDefined() 316 | }) 317 | 318 | it('"associateWaf()" should be invoked', async () => { 319 | plugin.associateWaf = jest.fn() 320 | plugin.associateWaf.mockResolvedValueOnce({}) 321 | await plugin.updateWafAssociation() 322 | expect(plugin.associateWaf).toHaveBeenCalled() 323 | }) 324 | }) 325 | 326 | describe('with adding property "custom.associateWaf.version" !== "V2" ', () => { 327 | beforeEach(() => { 328 | serverless.service.custom = { 329 | associateWaf: { 330 | name: 'stage-service-name', 331 | version: "default-version" 332 | } 333 | } 334 | serverless.service.provider.compiledCloudFormationTemplate = { 335 | Outputs: ['ApiGatewayRestApiWaf', { Description: 'Rest API Id', Value: 'some-api-rest-id' }] 336 | } 337 | plugin = new AssociateWafPlugin(serverless, options) 338 | }) 339 | 340 | it('should set config', () => { 341 | expect(plugin.config).toBeTruthy() 342 | }) 343 | 344 | it(`wafVersion should be ${WAF_REGIONAL}`, () => { 345 | expect(plugin.wafVersion).toEqual(WAF_REGIONAL); 346 | }) 347 | 348 | it('should set hooks', () => { 349 | expect(plugin.hooks).toHaveProperty('after:deploy:deploy') 350 | expect(plugin.hooks).toHaveProperty('before:package:finalize') 351 | }) 352 | 353 | it('"outputRestApiId()" should be invoked', async () => { 354 | await expectOutputRestApiIdToHaveBeenCalled(plugin) 355 | }) 356 | 357 | it('should output restApiId', () => { 358 | plugin.updateCloudFormationTemplate() 359 | expect(serverless.service.provider.compiledCloudFormationTemplate.Outputs['ApiGatewayRestApiWaf']).toBeDefined() 360 | }) 361 | 362 | it('"associateWaf()" should be invoked', async () => { 363 | plugin.associateWaf = jest.fn() 364 | plugin.associateWaf.mockResolvedValueOnce({}) 365 | await plugin.updateWafAssociation() 366 | expect(plugin.associateWaf).toHaveBeenCalled() 367 | }) 368 | }) 369 | 370 | }) 371 | 372 | const mockStackResources = { 373 | StackResourceSummaries: [ 374 | { 375 | LogicalResourceId: 'some', 376 | PhysicalResourceId: 'thing' 377 | }, 378 | { 379 | LogicalResourceId: 'ApiGatewayRestApi', 380 | PhysicalResourceId: 'theRestApiId' 381 | } 382 | ] 383 | } 384 | 385 | const mockStackOutputs = { 386 | Stacks: [ 387 | { 388 | Outputs: [ 389 | { 390 | OutputKey: 'some', 391 | OutputValue: 'value' 392 | }, 393 | { 394 | OutputKey: 'ApiGatewayRestApiWaf', 395 | OutputValue: 'theRestApiId' 396 | } 397 | ] 398 | } 399 | ] 400 | } 401 | 402 | const mockStackMissingOutputs = { 403 | Stacks: [ 404 | { 405 | NotOutputs: [{}] 406 | } 407 | ] 408 | } 409 | 410 | describe('associateWaf()', () => { 411 | 412 | describe("associate Waf regional", () => { 413 | beforeEach(() => { 414 | serverless.service.custom = { 415 | associateWaf: { 416 | name: 'some-waf-name' 417 | } 418 | } 419 | plugin = new AssociateWafPlugin(serverless, options) 420 | }) 421 | 422 | it('should not lookup REST API ID from CloudFormation stack if specified by provider configuration', async () => { 423 | plugin.serverless.service.provider.apiGateway = { 424 | restApiId: 'something' 425 | } 426 | plugin.provider.request.mockResolvedValueOnce({}) 427 | 428 | await plugin.associateWaf() 429 | 430 | expect(plugin.provider.request).not.toHaveBeenCalledWith('CloudFormation', expect.anything(), expect.anything()) 431 | }) 432 | 433 | it('should log info when unable to find REST API ID from CloudFormation stack', async () => { 434 | plugin.provider.request.mockResolvedValue({}) 435 | 436 | await plugin.associateWaf() 437 | 438 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Unable to determine REST API ID')) 439 | }) 440 | 441 | it('should log info when unable to fund REST API ID from stack outputs', async () => { 442 | plugin.provider.request.mockResolvedValueOnce({}) 443 | plugin.provider.request.mockResolvedValueOnce(mockStackMissingOutputs) 444 | 445 | await plugin.associateWaf() 446 | 447 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Unable to determine REST API ID')) 448 | }) 449 | 450 | it('should log info when unable to find WAF', async () => { 451 | plugin.provider.request 452 | .mockResolvedValueOnce(mockStackResources) 453 | .mockResolvedValueOnce(mockStackOutputs) 454 | .mockResolvedValueOnce({}) 455 | 456 | await plugin.associateWaf() 457 | 458 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Unable to find WAF named')) 459 | }) 460 | 461 | it('should log error when exception caught', async () => { 462 | spy = await setupAwsErrorMessage(plugin) 463 | await plugin.associateWaf() 464 | expect(spy).toHaveBeenLastCalledWith(expect.stringContaining(AWSErrorMessage)) 465 | }) 466 | 467 | it('should associate WAF', async () => { 468 | const mockWebAcls = { 469 | WebACLs: [ 470 | { 471 | Name: 'skip-waf-name', 472 | WebACLId: 'skip-waf-id' 473 | }, 474 | { 475 | Name: 'some-waf-name', 476 | WebACLId: 'some-waf-id' 477 | } 478 | ] 479 | } 480 | 481 | plugin.provider.request 482 | .mockResolvedValueOnce(mockStackResources) 483 | .mockResolvedValueOnce(mockWebAcls) 484 | 485 | await plugin.associateWaf() 486 | 487 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Associating WAF...')) 488 | }) 489 | 490 | it('should associate WAF with split stacks plugin', async () => { 491 | const mockWebAcls = { 492 | WebACLs: [ 493 | { 494 | Name: 'skip-waf-name', 495 | WebACLId: 'skip-waf-id' 496 | }, 497 | { 498 | Name: 'some-waf-name', 499 | WebACLId: 'some-waf-id' 500 | } 501 | ] 502 | } 503 | 504 | plugin.provider.request 505 | .mockResolvedValueOnce({}) 506 | .mockResolvedValueOnce(mockStackOutputs) 507 | .mockResolvedValueOnce(mockWebAcls) 508 | 509 | await plugin.associateWaf() 510 | 511 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Associating WAF...')) 512 | }) 513 | }) 514 | 515 | describe("associate Waf V2", () => { 516 | beforeEach(() => { 517 | serverless.service.custom = { 518 | associateWaf: { 519 | name: 'some-waf-name', 520 | version: "V2" 521 | } 522 | } 523 | plugin = new AssociateWafPlugin(serverless, options) 524 | }) 525 | 526 | it('should not lookup REST API ID from CloudFormation stack if specified by provider configuration', async () => { 527 | plugin.serverless.service.provider.apiGateway = { 528 | restApiId: 'something' 529 | } 530 | plugin.provider.request.mockResolvedValueOnce({}) 531 | 532 | await plugin.associateWaf() 533 | 534 | expect(plugin.provider.request).not.toHaveBeenCalledWith('CloudFormation', expect.anything(), expect.anything()) 535 | }) 536 | 537 | it('should log info when unable to find REST API ID from CloudFormation stack', async () => { 538 | plugin.provider.request.mockResolvedValue({}) 539 | 540 | await plugin.associateWaf() 541 | 542 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Unable to determine REST API ID')) 543 | }) 544 | 545 | it('should log info when unable to fund REST API ID from stack outputs', async () => { 546 | plugin.provider.request.mockResolvedValueOnce({}) 547 | plugin.provider.request.mockResolvedValueOnce(mockStackMissingOutputs) 548 | 549 | await plugin.associateWaf() 550 | 551 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Unable to determine REST API ID')) 552 | }) 553 | 554 | it('should log info when unable to find WAF', async () => { 555 | plugin.provider.request 556 | .mockResolvedValueOnce(mockStackResources) 557 | .mockResolvedValueOnce(mockStackOutputs) 558 | .mockResolvedValueOnce({}) 559 | 560 | await plugin.associateWaf() 561 | 562 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Unable to find WAF named')) 563 | }) 564 | 565 | it('should log error when exception caught', async () => { 566 | spy = await setupAwsErrorMessage(plugin) 567 | await plugin.associateWaf() 568 | expect(spy).toHaveBeenLastCalledWith(expect.stringContaining(AWSErrorMessage)) 569 | }) 570 | 571 | it('should associate WAF', async () => { 572 | const mockWebAcls = { 573 | WebACLs: [ 574 | { 575 | Name: 'skip-waf-name', 576 | ARN: 'skip-waf-arn' 577 | }, 578 | { 579 | Name: 'some-waf-name', 580 | ARN: 'some-waf-arn' 581 | } 582 | ] 583 | } 584 | 585 | plugin.provider.request 586 | .mockResolvedValueOnce(mockStackResources) 587 | .mockResolvedValueOnce(mockWebAcls) 588 | 589 | await plugin.associateWaf() 590 | 591 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Associating WAF...')) 592 | }) 593 | 594 | it('should associate WAF with split stacks plugin', async () => { 595 | const mockWebAcls = { 596 | WebACLs: [ 597 | { 598 | Name: 'skip-waf-name', 599 | ARN: 'skip-waf-arn' 600 | }, 601 | { 602 | Name: 'some-waf-name', 603 | ARN: 'some-waf-arn' 604 | } 605 | ] 606 | } 607 | 608 | plugin.provider.request 609 | .mockResolvedValueOnce({}) 610 | .mockResolvedValueOnce(mockStackOutputs) 611 | .mockResolvedValueOnce(mockWebAcls) 612 | 613 | await plugin.associateWaf() 614 | 615 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Associating WAF...')) 616 | }) 617 | 618 | }) 619 | }) 620 | 621 | describe('disassociateWaf()', () => { 622 | beforeEach(() => { 623 | serverless.service.custom = {} 624 | plugin = new AssociateWafPlugin(serverless, options) 625 | }) 626 | 627 | it('should log info when unable to find REST API ID from CloudFormation stack', async () => { 628 | plugin.provider.request.mockResolvedValue({}) 629 | 630 | await plugin.disassociateWaf() 631 | 632 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Unable to determine REST API ID')) 633 | }) 634 | 635 | it('should log error when exception caught', async () => { 636 | spy = await setupAwsErrorMessage(plugin) 637 | await plugin.disassociateWaf() 638 | expect(spy).toHaveBeenLastCalledWith(expect.stringContaining(AWSErrorMessage)) 639 | }) 640 | 641 | it('should disassociate WAF if associated', async () => { 642 | const mockWebAcls = { 643 | WebACLSummary: { 644 | WebACLId: 'some-waf-id', 645 | Name: 'some-waf-name' 646 | } 647 | } 648 | 649 | plugin.provider.request 650 | .mockResolvedValueOnce(mockStackResources) 651 | .mockResolvedValueOnce(mockWebAcls) 652 | .mockResolvedValueOnce({}) 653 | 654 | await plugin.disassociateWaf() 655 | 656 | expect(plugin.serverless.cli.log).toHaveBeenLastCalledWith(expect.stringContaining('Disassociating WAF...')) 657 | }) 658 | 659 | it('should not disassociate WAF if associated', async () => { 660 | const mockWebAcls = { } 661 | 662 | plugin.provider.request 663 | .mockResolvedValueOnce(mockStackResources) 664 | .mockResolvedValueOnce(mockStackOutputs) 665 | .mockResolvedValueOnce(mockWebAcls) 666 | .mockResolvedValueOnce({}) 667 | 668 | await plugin.disassociateWaf() 669 | 670 | expect(plugin.serverless.cli.log).not.toHaveBeenLastCalledWith(expect.stringContaining('Disassociating WAF...')) 671 | }) 672 | 673 | }) 674 | 675 | async function setupAwsErrorMessage(plugin){ 676 | const spy = jest.spyOn(console, 'error') 677 | plugin.provider.request 678 | .mockRejectedValueOnce(new Error(AWSErrorMessage)) 679 | return spy 680 | } 681 | 682 | async function expectDisassociateWafToHaveBeenCalled(plugin){ 683 | plugin.disassociateWaf = jest.fn() 684 | plugin.disassociateWaf.mockResolvedValueOnce({}) 685 | await plugin.updateWafAssociation() 686 | expect(plugin.disassociateWaf).toHaveBeenCalled() 687 | } 688 | 689 | async function expectOutputRestApiIdToHaveBeenCalled(plugin){ 690 | plugin.outputRestApiId = jest.fn() 691 | plugin.outputRestApiId.mockResolvedValueOnce({}) 692 | await plugin.updateCloudFormationTemplate() 693 | expect(plugin.outputRestApiId).toHaveBeenCalled() 694 | } 695 | 696 | }) 697 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-associate-waf", 3 | "version": "1.2.1", 4 | "description": "Associate a regional WAF with the AWS API Gateway used by your Serverless stack.", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "lib": "lib" 8 | }, 9 | "scripts": { 10 | "test": "jest --env node --coverage", 11 | "coveralls": "cat ./coverage/lcov.info | coveralls" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/MikeSouza/serverless-associate-waf.git" 16 | }, 17 | "keywords": [ 18 | "serverless", 19 | "serverless framework", 20 | "serverless plugin", 21 | "serverless plugins", 22 | "amazon web services", 23 | "aws", 24 | "aws api gateway", 25 | "api gateway", 26 | "apigateway", 27 | "aws waf", 28 | "waf" 29 | ], 30 | "author": "Mike Souza", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/MikeSouza/serverless-associate-waf/issues" 34 | }, 35 | "homepage": "https://github.com/MikeSouza/serverless-associate-waf#readme", 36 | "publishConfig": { 37 | "registry": "https://registry.npmjs.org", 38 | "access": "public" 39 | }, 40 | "devDependencies": { 41 | "coveralls": "^3.0.9", 42 | "jest": "^25.1.0", 43 | "serverless": "^1.63.0" 44 | }, 45 | "dependencies": { 46 | "chalk": "^2.4.2" 47 | } 48 | } 49 | --------------------------------------------------------------------------------