From e99c51ab10a257a051672f53c211bc2a6674bd32 Mon Sep 17 00:00:00 2001 From: Yasuto Nishii <45926314+yasuto-nishii@users.noreply.github.com> Date: Sun, 11 Feb 2024 18:42:48 +0900 Subject: [PATCH 1/6] fix : support overrideAction for WAF (#582) --- doc/WAF.md | 16 +++++++++++++++ src/__tests__/__snapshots__/waf.test.ts.snap | 21 ++++++++++++++++++++ src/__tests__/waf.test.ts | 21 ++++++++++++++++++++ src/resources/Waf.ts | 9 ++++++++- 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/doc/WAF.md b/doc/WAF.md index 40d636f0..13916f40 100644 --- a/doc/WAF.md +++ b/doc/WAF.md @@ -119,6 +119,22 @@ waf: - US ``` +```yml +waf: + enabled: true + defaultAction: Block + rules: + # using ManagedRuleGroup + - name: "AWSManagedRulesCommonRuleSet" + priority: 20 + overrideAction: + None: {} + statement: + ManagedRuleGroupStatement: + VendorName: "AWS" + Name: "AWSManagedRulesCommonRuleSet" +``` + ### Per API Key rules In some cases, you might want to enable a rule for a given API key only. You can specify `wafRules` under the `appSync.apiKeys` attribute. The rules will apply only to that API key. diff --git a/src/__tests__/__snapshots__/waf.test.ts.snap b/src/__tests__/__snapshots__/waf.test.ts.snap index 4101842f..5d48bbc9 100644 --- a/src/__tests__/__snapshots__/waf.test.ts.snap +++ b/src/__tests__/__snapshots__/waf.test.ts.snap @@ -411,6 +411,27 @@ Object { } `; +exports[`Waf Custom rules should generate a custom rule with ManagedRuleGroup 1`] = ` +Object { + "Name": "MyRule1", + "OverrideAction": Object { + "None": Object {}, + }, + "Priority": 200, + "Statement": Object { + "ManagedRuleGroupStatement": Object { + "Name": "AWSManagedRulesCommonRuleSet", + "VendorName": "AWS", + }, + }, + "VisibilityConfig": Object { + "CloudWatchMetricsEnabled": true, + "MetricName": "MyRule1", + "SampledRequestsEnabled": true, + }, +} +`; + exports[`Waf Disable introspection should generate a preset rule 1`] = ` Object { "Action": Object { diff --git a/src/__tests__/waf.test.ts b/src/__tests__/waf.test.ts index 9575f66b..9cdc83c9 100644 --- a/src/__tests__/waf.test.ts +++ b/src/__tests__/waf.test.ts @@ -168,6 +168,27 @@ describe('Waf', () => { ), ).toMatchSnapshot(); }); + + it('should generate a custom rule with ManagedRuleGroup', () => { + expect( + waf.buildWafRule( + { + name: 'MyRule1', + priority: 200, + overrideAction: { + None: {}, + }, + statement: { + ManagedRuleGroupStatement: { + Name: 'AWSManagedRulesCommonRuleSet', + VendorName: 'AWS', + }, + }, + }, + 'Base', + ), + ).toMatchSnapshot(); + }); }); describe('ApiKey rules', () => { diff --git a/src/resources/Waf.ts b/src/resources/Waf.ts index b9a6dfa5..2d2e0bc7 100644 --- a/src/resources/Waf.ts +++ b/src/resources/Waf.ts @@ -14,6 +14,7 @@ import { WafThrottleConfig, } from '../types/plugin'; import { Api } from './Api'; +import { toCfnKeys } from '../utils'; export class Waf { constructor(private api: Api, private config: WafConfig) {} @@ -106,10 +107,10 @@ export class Waf { } const action: WafRuleAction = rule.action || 'Allow'; + const overrideAction = rule.overrideAction; const result: CfnWafRule = { Name: rule.name, - Action: { [action]: {} }, Priority: rule.priority, Statement: rule.statement, VisibilityConfig: this.getWafVisibilityConfig( @@ -118,6 +119,12 @@ export class Waf { ), }; + if (overrideAction) { + result.OverrideAction = toCfnKeys(overrideAction); + } else { + result.Action = { [action]: {} }; + } + return result; } From 3b56bf248357cd9279062030282dc4cdcde3fcfc Mon Sep 17 00:00:00 2001 From: Max Marze Date: Mon, 12 Aug 2024 09:24:39 -0400 Subject: [PATCH 2/6] feat: support serverless v4 (#637) --- src/__tests__/commands.test.ts | 27 ---------- src/__tests__/given.ts | 15 +++++- src/index.ts | 94 +++++++++++++++++++++------------- src/resources/Schema.ts | 2 +- src/types/common.ts | 1 + 5 files changed, 74 insertions(+), 65 deletions(-) diff --git a/src/__tests__/commands.test.ts b/src/__tests__/commands.test.ts index 4de3ebc0..3fa9b2dc 100644 --- a/src/__tests__/commands.test.ts +++ b/src/__tests__/commands.test.ts @@ -4,33 +4,6 @@ import ServerlessError from 'serverless/lib/serverless-error'; jest.setTimeout(30000); -jest.mock('@serverless/utils/log', () => { - const dummyProgress = { - update: jest.fn(), - remove: jest.fn(), - }; - const logger = { - error: jest.fn(), - warning: jest.fn(), - notice: jest.fn(), - info: jest.fn(), - debug: jest.fn(), - success: jest.fn(), - }; - return { - writeText: jest.fn(), - progress: { - get: () => dummyProgress, - create: () => dummyProgress, - }, - log: { - get: () => logger, - ...logger, - }, - getPluginWriters: jest.fn(), - }; -}); - const confirmSpy = jest.spyOn(utils, 'confirmAction'); const describeStackResources = jest.fn().mockResolvedValue({ StackResources: [ diff --git a/src/__tests__/given.ts b/src/__tests__/given.ts index 63ab67c9..6fbed2d6 100644 --- a/src/__tests__/given.ts +++ b/src/__tests__/given.ts @@ -22,7 +22,20 @@ export const plugin = () => { stage: 'dev', region: 'us-east-1', }; - return new ServerlessAppsyncPlugin(createServerless(), options); + return new ServerlessAppsyncPlugin(createServerless(), options, { + log: { + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + success: jest.fn(), + }, + progress: { + create: () => ({ + remove: jest.fn(), + }), + }, + writeText: jest.fn(), + }); }; export const appSyncConfig = (partial?: Partial) => { diff --git a/src/index.ts b/src/index.ts index 89e15d12..da62e3e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import { writeText, log, progress } from '@serverless/utils/log'; import Serverless from 'serverless/lib/Serverless'; import Provider from 'serverless/lib/plugins/aws/provider.js'; import { forEach, last, merge } from 'lodash'; @@ -68,6 +67,23 @@ import terminalLink from 'terminal-link'; const CONSOLE_BASE_URL = 'https://console.aws.amazon.com'; +type Progress = { + remove: () => void; +}; + +type ServerlessPluginUtils = { + log: { + success: (message: string) => void; + warning: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; + }; + progress: { + create: (params: { name?: string; message: string }) => Progress; + }; + writeText: (message: string) => void; +}; + class ServerlessAppsyncPlugin { private provider: Provider; private gatheredData: { @@ -90,6 +106,7 @@ class ServerlessAppsyncPlugin { constructor( public serverless: Serverless, private options: Record, + public utils: ServerlessPluginUtils, ) { this.gatheredData = { apis: [], @@ -98,7 +115,7 @@ class ServerlessAppsyncPlugin { this.serverless = serverless; this.options = options; this.provider = this.serverless.getProvider('aws'); - + this.utils = utils; // We are using a newer version of AJV than Serverless Framework // and some customizations (eg: custom errors, $merge, filter irrelevant errors) // For SF, just validate the type of input to allow us to use a custom @@ -304,7 +321,7 @@ class ServerlessAppsyncPlugin { 'appsync:validate-schema:run': () => { this.loadConfig(); this.validateSchemas(); - log.success('AppSync schema valid'); + this.utils.log.success('AppSync schema valid'); }, 'appsync:get-introspection:run': () => this.getIntrospection(), 'appsync:flush-cache:run': () => this.flushCache(), @@ -327,7 +344,7 @@ class ServerlessAppsyncPlugin { this.initDomainCommand(), 'appsync:domain:delete-record:run': async () => this.deleteRecord(), finalize: () => { - writeText( + this.utils.writeText( '\nLooking for a better AppSync development experience? Have you tried GraphBolt? https://graphbolt.dev', ); }, @@ -431,20 +448,22 @@ class ServerlessAppsyncPlugin { try { const filePath = path.resolve(this.options.output); fs.writeFileSync(filePath, schema.toString()); - log.success(`Introspection schema exported to ${filePath}`); + this.utils.log.success(`Introspection schema exported to ${filePath}`); } catch (error) { - log.error(`Could not save to file: ${(error as Error).message}`); + this.utils.log.error( + `Could not save to file: ${(error as Error).message}`, + ); } return; } - writeText(schema.toString()); + this.utils.writeText(schema.toString()); } async flushCache() { const apiId = await this.getApiId(); await this.provider.request('AppSync', 'flushApiCache', { apiId }); - log.success('Cache flushed successfully'); + this.utils.log.success('Cache flushed successfully'); } async openConsole() { @@ -486,7 +505,7 @@ class ServerlessAppsyncPlugin { events?.forEach((event) => { const { timestamp, message } = event; - writeText( + this.utils.writeText( `${chalk.gray( DateTime.fromMillis(timestamp || 0).toISO(), )}\t${message}`, @@ -512,7 +531,7 @@ class ServerlessAppsyncPlugin { const domain = this.getDomain(); if (domain.useCloudFormation !== false) { - log.warning( + this.utils.log.warning( 'You are using the CloudFormation integration for domain configuration.\n' + 'To avoid CloudFormation drifts, you should not use it in combination with this command.\n' + 'Set the `domain.useCloudFormation` attribute to false to use the CLI integration.\n' + @@ -568,7 +587,7 @@ class ServerlessAppsyncPlugin { ({ DomainName }) => DomainName === match, ); if (cert) { - log.info( + this.utils.log.info( `Found matching certificate for ${match}: ${cert.CertificateArn}`, ); return cert.CertificateArn; @@ -595,13 +614,13 @@ class ServerlessAppsyncPlugin { domainName: domain.name, certificateArn, }); - log.success(`Domain '${domain.name}' created successfully`); + this.utils.log.success(`Domain '${domain.name}' created successfully`); } catch (error) { if ( error instanceof this.serverless.classes.Error && this.options.quiet ) { - log.error(error.message); + this.utils.log.error(error.message); } else { throw error; } @@ -611,7 +630,7 @@ class ServerlessAppsyncPlugin { async deleteDomain() { try { const domain = this.getDomain(); - log.warning(`The domain '${domain.name} will be deleted.`); + this.utils.log.warning(`The domain '${domain.name} will be deleted.`); if (!this.options.yes && !(await confirmAction())) { return; } @@ -621,13 +640,13 @@ class ServerlessAppsyncPlugin { >('AppSync', 'deleteDomainName', { domainName: domain.name, }); - log.success(`Domain '${domain.name}' deleted successfully`); + this.utils.log.success(`Domain '${domain.name}' deleted successfully`); } catch (error) { if ( error instanceof this.serverless.classes.Error && this.options.quiet ) { - log.error(error.message); + this.utils.log.error(error.message); } else { throw error; } @@ -663,8 +682,7 @@ class ServerlessAppsyncPlugin { message: string; desiredStatus: 'SUCCESS' | 'NOT_FOUND'; }) { - const progressInstance = progress.create({ message }); - + const progressInstance = this.utils.progress.create({ message }); let status: string; do { status = @@ -683,14 +701,14 @@ class ServerlessAppsyncPlugin { const assoc = await this.getApiAssocStatus(domain.name); if (assoc?.associationStatus !== 'NOT_FOUND' && assoc?.apiId !== apiId) { - log.warning( + this.utils.log.warning( `The domain ${domain.name} is currently associated to another API (${assoc?.apiId})`, ); if (!this.options.yes && !(await confirmAction())) { return; } } else if (assoc?.apiId === apiId) { - log.success('The domain is already associated to this API'); + this.utils.log.success('The domain is already associated to this API'); return; } @@ -709,7 +727,9 @@ class ServerlessAppsyncPlugin { message, desiredStatus: 'SUCCESS', }); - log.success(`API successfully associated to domain '${domain.name}'`); + this.utils.log.success( + `API successfully associated to domain '${domain.name}'`, + ); } async disassocDomain() { @@ -718,7 +738,7 @@ class ServerlessAppsyncPlugin { const assoc = await this.getApiAssocStatus(domain.name); if (assoc?.associationStatus === 'NOT_FOUND') { - log.warning( + this.utils.log.warning( `The domain ${domain.name} is currently not associated to any API`, ); return; @@ -730,7 +750,7 @@ class ServerlessAppsyncPlugin { `Try running this command from that API's stack or stage, or use the --force / -f flag`, ); } - log.warning( + this.utils.log.warning( `The domain ${domain.name} will be disassociated from API '${apiId}'`, ); @@ -752,7 +772,9 @@ class ServerlessAppsyncPlugin { desiredStatus: 'NOT_FOUND', }); - log.success(`API successfully disassociated from domain '${domain.name}'`); + this.utils.log.success( + `API successfully disassociated from domain '${domain.name}'`, + ); } async getHostedZoneId() { @@ -798,7 +820,7 @@ class ServerlessAppsyncPlugin { } async createRecord() { - const progressInstance = progress.create({ + const progressInstance = this.utils.progress.create({ message: 'Creating route53 record', }); @@ -813,10 +835,10 @@ class ServerlessAppsyncPlugin { if (changeId) { await this.checkRoute53RecordStatus(changeId); progressInstance.remove(); - log.info( + this.utils.log.info( `Alias record for '${domain.name}' was created in Hosted Zone '${hostedZoneId}'`, ); - log.success('Route53 record created successfuly'); + this.utils.log.success('Route53 record created successfuly'); } } @@ -825,14 +847,14 @@ class ServerlessAppsyncPlugin { const appsyncDomainName = await this.getAppSyncDomainName(); const hostedZoneId = await this.getHostedZoneId(); - log.warning( + this.utils.log.warning( `Alias record for '${domain.name}' will be deleted from Hosted Zone '${hostedZoneId}'`, ); if (!this.options.yes && !(await confirmAction())) { return; } - const progressInstance = progress.create({ + const progressInstance = this.utils.progress.create({ message: 'Deleting route53 record', }); @@ -844,10 +866,10 @@ class ServerlessAppsyncPlugin { if (changeId) { await this.checkRoute53RecordStatus(changeId); progressInstance.remove(); - log.info( + this.utils.log.info( `Alias record for '${domain.name}' was deleted from Hosted Zone '${hostedZoneId}'`, ); - log.success('Route53 record deleted successfuly'); + this.utils.log.success('Route53 record deleted successfuly'); } } @@ -905,7 +927,7 @@ class ServerlessAppsyncPlugin { error instanceof this.serverless.classes.Error && this.options.quiet ) { - log.error(error.message); + this.utils.log.error(error.message); } else { throw error; } @@ -949,7 +971,7 @@ class ServerlessAppsyncPlugin { } loadConfig() { - log.info('Loading AppSync config'); + this.utils.log.info('Loading AppSync config'); const { appSync } = this.serverless.configurationInput; @@ -969,7 +991,7 @@ class ServerlessAppsyncPlugin { validateSchemas() { try { - log.info('Validating AppSync schema'); + this.utils.log.info('Validating AppSync schema'); if (!this.api) { throw new this.serverless.classes.Error( 'Could not load the API. This should not happen.', @@ -977,7 +999,7 @@ class ServerlessAppsyncPlugin { } this.api.compileSchema(); } catch (error) { - log.info('Error'); + this.utils.log.info('Error'); if (error instanceof GraphQLError) { this.handleError(error.message); } @@ -1057,7 +1079,7 @@ class ServerlessAppsyncPlugin { if (configValidationMode === 'error') { throw new this.serverless.classes.Error(message); } else if (configValidationMode === 'warn') { - log.warning(message); + this.utils.log.warning(message); } } } diff --git a/src/resources/Schema.ts b/src/resources/Schema.ts index 916c3fc3..1c958d13 100644 --- a/src/resources/Schema.ts +++ b/src/resources/Schema.ts @@ -50,7 +50,7 @@ export class Schema { valdiateSchema(schema: string) { const errors = validateSDL(parse(schema)); if (errors.length > 0) { - throw new ServerlessError( + throw new this.api.plugin.serverless.classes.Error( 'Invalid GraphQL schema:\n' + errors.map((error) => ` ${error.message}`).join('\n'), ); diff --git a/src/types/common.ts b/src/types/common.ts index cc8157b9..ae8ced7e 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -51,6 +51,7 @@ export type WafRuleCustom = { action?: WafRuleAction; statement: CfnWafRuleStatement; visibilityConfig?: VisibilityConfig; + overrideAction?: Record; }; export type WafRuleDisableIntrospection = { From b2e647a42f651c66e96dd95755785168c333bbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bour=C3=A9?= Date: Thu, 10 Oct 2024 15:28:03 +0200 Subject: [PATCH 3/6] doc: Fix broken link --- doc/general-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/general-config.md b/doc/general-config.md index 8cb91965..0cf68aca 100644 --- a/doc/general-config.md +++ b/doc/general-config.md @@ -45,7 +45,7 @@ appSync: - `resolvers`: See [Resolvers](resolvers.md) - `pipelineFunctions`: See [Pipeline functions](pipeline-functions.md) - `substitutions`: See [Substitutions](substitutions.md). Deprecated: Use environment variables. -- `environment`: A list of environment variables for the API. See [Official Documentation](https://docs.aws.amazon.com/appsync/latest/devguide/environmental-variables.html) +- `environment`: A list of environment variables for the API. See [Official Documentation](https://docs.aws.amazon.com/appsync/latest/devguide/environment-variables.html) - `caching`: See [Cacing](caching.md) - `waf`: See [Web Application Firefall](WAF.md) - `logging`: See [Logging](#Logging) From 5d49f3078f4065e2a49e396a776712ab4db3bbdd Mon Sep 17 00:00:00 2001 From: Mohammed Date: Mon, 14 Oct 2024 16:29:53 +0530 Subject: [PATCH 4/6] feat: add new log levels (#642) Co-authored-by: Mohammed Izzy --- src/types/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/common.ts b/src/types/common.ts index ae8ced7e..bb360aa6 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -229,7 +229,7 @@ export type VisibilityConfig = { }; export type LoggingConfig = { - level: 'ERROR' | 'NONE' | 'ALL'; + level: 'ERROR' | 'NONE' | 'ALL' | 'DEBUG' | 'INFO'; enabled?: boolean; excludeVerboseContent?: boolean; retentionInDays?: number; From bb6a8df621ef113730daf71d7954ad7544a2acd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bour=C3=A9?= Date: Mon, 14 Oct 2024 13:01:37 +0200 Subject: [PATCH 5/6] doc: add new logLevel values --- doc/general-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/general-config.md b/doc/general-config.md index 0cf68aca..19c08d17 100644 --- a/doc/general-config.md +++ b/doc/general-config.md @@ -186,7 +186,7 @@ appSync: retentionInDays: 14 ``` -- `level`: `ERROR`, `NONE`, or `ALL` +- `level`: `ERROR`, `NONE`, `INFO`, `DEBUG` or `ALL` - `enabled`: Boolean, Optional. Defaults to `true` when `logging` is present. - `excludeVerboseContent`: Boolean, Optional. Exclude or not verbose content (headers, response headers, context, and evaluated mapping templates), regardless of field logging level. Defaults to `false`. - `retentionInDays`: Optional. Number of days to retain the logs. Defaults to [`provider.logRetentionInDays`](https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml#general-function-settings). From 53cb65206916ee42223a85450ea53e428b9fb502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bour=C3=A9?= Date: Thu, 17 Oct 2024 08:42:59 +0200 Subject: [PATCH 6/6] fix: log level validation (#643) --- src/__tests__/validation/__snapshots__/base.test.ts.snap | 2 +- src/validation.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/__tests__/validation/__snapshots__/base.test.ts.snap b/src/__tests__/validation/__snapshots__/base.test.ts.snap index b2b9d274..7afdb44d 100644 --- a/src/__tests__/validation/__snapshots__/base.test.ts.snap +++ b/src/__tests__/validation/__snapshots__/base.test.ts.snap @@ -30,7 +30,7 @@ exports[`Valdiation Domain Invalid should validate a useCloudFormation: not pres exports[`Valdiation Domain Invalid should validate a useCloudFormation: true, certificateArn or hostedZoneId is required 1`] = `"/domain: when using CloudFormation, you must provide either certificateArn or hostedZoneId."`; exports[`Valdiation Log Invalid should validate a Invalid 1`] = ` -"/logging/level: must be one of 'ALL', 'ERROR' or 'NONE' +"/logging/level: must be one of 'ALL', 'INFO', 'DEBUG', 'ERROR' or 'NONE' /logging/retentionInDays: must be integer /logging/excludeVerboseContent: must be boolean" `; diff --git a/src/validation.ts b/src/validation.ts index 4cc292e0..0a961928 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -801,8 +801,9 @@ export const appSyncSchema = { roleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, level: { type: 'string', - enum: ['ALL', 'ERROR', 'NONE'], - errorMessage: "must be one of 'ALL', 'ERROR' or 'NONE'", + enum: ['ALL', 'INFO', 'DEBUG', 'ERROR', 'NONE'], + errorMessage: + "must be one of 'ALL', 'INFO', 'DEBUG', 'ERROR' or 'NONE'", }, retentionInDays: { type: 'integer' }, excludeVerboseContent: { type: 'boolean' },