[7.17] [ResponseOps][actions] add config for allow-listing email address domains (#129001) (#131014)

resolves https://github.com/elastic/kibana/issues/126944

Adds a new configuration setting for the actions plugin,
xpack.actions.email.domain_allowlist, which is an array of domain name
strings which are allowed to be sent emails by the email connector.

backport of https://github.com/elastic/kibana/pull/129001

* additional changes after merge conflicts during cherry pick for backport
This commit is contained in:
Patrick Mueller 2022-04-26 17:43:16 -04:00 committed by GitHub
parent f0b42f06a8
commit fb32471f11
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1293 additions and 242 deletions

View file

@ -9,6 +9,8 @@ The email connector uses the SMTP protocol to send mail messages, using an integ
NOTE: For emails to have a footer with a link back to {kib}, set the <<server-publicBaseUrl, `server.publicBaseUrl`>> configuration setting.
NOTE: When the <<action-config-email-domain-allowlist, `xpack.actions.email.domain_allowlist`>> configuration setting is used, the email addresses used for all of the Sender (from), To, CC, and BCC properties must have email domains specified in the configuration setting.
[float]
[[email-connector-configuration]]
==== Connector configuration

View file

@ -130,6 +130,11 @@ The contents of a PEM-encoded certificate file, or multiple files appended
into a single string. This configuration can be used for environments where
the files cannot be made available.
[[action-config-email-domain-allowlist]] `xpack.actions.email.domain_allowlist` {ess-icon}::
A list of allowed email domains which can be used with the email connector. When this setting is not used, all email domains are allowed. When this setting is used, if any email is attempted to be sent that includes an addressee with an email domain that is not in the allowlist, or the from address domain is not in the allowlist, the run of the connector will fail with a message indicating the emails not allowed.
WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such this settings should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly.
`xpack.actions.enabledActionTypes` {ess-icon}::
A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, and `.webhook`. An empty list `[]` will disable all action types.
+

View file

@ -224,6 +224,7 @@
"deepmerge": "^4.2.2",
"del": "^5.1.0",
"elastic-apm-node": "^3.21.1",
"email-addresses": "^5.0.0",
"execa": "^4.0.2",
"exit-hook": "^2.2.0",
"expiry-js": "0.1.7",

View file

@ -1,5 +1,6 @@
pageLoadAssetSize:
advancedSettings: 27596
actions: 20000
alerting: 106936
apm: 64385
canvas: 1066647

View file

@ -204,6 +204,7 @@ kibana_vars=(
xpack.actions.allowedHosts
xpack.actions.customHostSettings
xpack.actions.enabled
xpack.actions.email.domain_allowlist
xpack.actions.enabledActionTypes
xpack.actions.maxResponseContentLength
xpack.actions.preconfigured

View file

@ -141,6 +141,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'usageCollection.uiCounters.debug (boolean)',
'usageCollection.uiCounters.enabled (boolean)',
'vis_type_vega.enableExternalUrls (boolean)',
'xpack.actions.email.domain_allowlist (array)',
'xpack.apm.profilingEnabled (boolean)',
'xpack.apm.serviceMapEnabled (boolean)',
'xpack.apm.ui.enabled (boolean)',

View file

@ -11,6 +11,9 @@
export * from './types';
export * from './alert_history_schema';
export * from './rewrite_request_case';
export * from './mustache_template';
export * from './validate_email_addresses';
export * from './servicenow_config';
export const BASE_ACTION_API_PATH = '/api/actions';
export const INTERNAL_BASE_ACTION_API_PATH = '/internal/actions';

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { hasMustacheTemplate, withoutMustacheTemplate } from './mustache_template';
const nonMustacheEmails = ['', 'zero@a.b.c', '}}{{'];
const mustacheEmails = ['{{}}', '"bob" {{}}@elastic.co', 'sneaky{{\n}}pete'];
describe('mustache_template', () => {
it('hasMustacheTemplate', () => {
for (const email of nonMustacheEmails) {
expect(hasMustacheTemplate(email)).toBe(false);
}
for (const email of mustacheEmails) {
expect(hasMustacheTemplate(email)).toBe(true);
}
});
it('withoutMustacheTemplate', () => {
let result = withoutMustacheTemplate(nonMustacheEmails);
expect(result).toEqual(nonMustacheEmails);
result = withoutMustacheTemplate(mustacheEmails);
expect(result).toEqual([]);
result = withoutMustacheTemplate(mustacheEmails.concat(nonMustacheEmails));
expect(result).toEqual(nonMustacheEmails);
});
});

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const MustacheInEmailRegExp = /\{\{((.|\n)*)\}\}/;
/** does the string contain `{{.*}}`? */
export function hasMustacheTemplate(string: string): boolean {
return !!string.match(MustacheInEmailRegExp);
}
/** filter strings that do not contain `{{.*}}` */
export function withoutMustacheTemplate(strings: string[]): string[] {
return strings.filter((string) => !hasMustacheTemplate(string));
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const serviceNowITSMTable = 'incident';
export const serviceNowSIRTable = 'sn_si_incident';
export const ServiceNowITSMActionTypeId = '.servicenow';
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
export const ServiceNowITOMActionTypeId = '.servicenow-itom';
const SN_ITSM_APP_ID = '7148dbc91bf1f450ced060a7234bcb88';
const SN_SIR_APP_ID = '2f0746801baeb01019ae54e4604bcb0f';
export interface SNProductsConfigValue {
table: string;
appScope: string;
useImportAPI: boolean;
importSetTable: string;
commentFieldKey: string;
appId?: string;
}
export type SNProductsConfig = Record<string, SNProductsConfigValue>;
export const snExternalServiceConfig: SNProductsConfig = {
'.servicenow': {
importSetTable: 'x_elas2_inc_int_elastic_incident',
appScope: 'x_elas2_inc_int',
table: 'incident',
useImportAPI: true,
commentFieldKey: 'work_notes',
appId: SN_ITSM_APP_ID,
},
'.servicenow-sir': {
importSetTable: 'x_elas2_sir_int_elastic_si_incident',
appScope: 'x_elas2_sir_int',
table: 'sn_si_incident',
useImportAPI: true,
commentFieldKey: 'work_notes',
appId: SN_SIR_APP_ID,
},
'.servicenow-itom': {
importSetTable: 'x_elas2_inc_int_elastic_incident',
appScope: 'x_elas2_inc_int',
table: 'em_event',
useImportAPI: false,
commentFieldKey: 'work_notes',
},
};
export const FIELD_PREFIX = 'u_';
export const DEFAULT_ALERTS_GROUPING_KEY = '{{rule.id}}:{{alert.id}}';

View file

@ -16,6 +16,17 @@ export interface ActionType {
minimumLicenseRequired: LicenseType;
}
export enum InvalidEmailReason {
invalid = 'invalid',
notAllowed = 'notAllowed',
}
export interface ValidatedEmail {
address: string;
valid: boolean;
reason?: InvalidEmailReason;
}
export interface ActionResult {
id: string;
actionTypeId: string;
@ -49,3 +60,7 @@ export function isActionTypeExecutorResult(
ActionTypeExecutorResultStatusValues.includes(unsafeResult?.status)
);
}
export interface ActionsPublicConfigType {
allowedEmailDomains: string[];
}

View file

@ -0,0 +1,284 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ValidatedEmail, InvalidEmailReason } from './types';
import {
validateEmailAddressesAsAlwaysValid,
validateEmailAddresses,
invalidEmailsAsMessage,
} from './validate_email_addresses';
const AllowedDomains = ['elastic.co', 'dev.elastic.co', 'found.no'];
const Emails = [
'bob@elastic.co',
'"Dr Tom" <tom@elastic.co>',
'jim@dev.elastic.co',
'rex@found.no',
'sal@alerting.dev.elastic.co',
'nancy@example.com',
'"Dr RFC 5322" <dr@rfc5322.org>',
'totally invalid',
'{{sneaky}}',
];
describe('validate_email_address', () => {
test('validateEmailAddressesAsAlwaysValid()', () => {
const emails = ['bob@example.com', 'invalid-email', ''];
const validatedEmails = validateEmailAddressesAsAlwaysValid(emails);
expect(validatedEmails).toMatchInlineSnapshot(`
Array [
Object {
"address": "bob@example.com",
"valid": true,
},
Object {
"address": "invalid-email",
"valid": true,
},
Object {
"address": "",
"valid": true,
},
]
`);
});
describe('validateEmailAddresses()', () => {
test('with configured allowlist and no mustache filtering', () => {
const result = validateEmailAddresses(AllowedDomains, Emails);
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"address": "bob@elastic.co",
"valid": true,
},
Object {
"address": "\\"Dr Tom\\" <tom@elastic.co>",
"valid": true,
},
Object {
"address": "jim@dev.elastic.co",
"valid": true,
},
Object {
"address": "rex@found.no",
"valid": true,
},
Object {
"address": "sal@alerting.dev.elastic.co",
"reason": "notAllowed",
"valid": false,
},
Object {
"address": "nancy@example.com",
"reason": "notAllowed",
"valid": false,
},
Object {
"address": "\\"Dr RFC 5322\\" <dr@rfc5322.org>",
"reason": "notAllowed",
"valid": false,
},
Object {
"address": "totally invalid",
"reason": "invalid",
"valid": false,
},
Object {
"address": "{{sneaky}}",
"reason": "invalid",
"valid": false,
},
]
`);
});
test('with configured allowlist and mustache filtering', () => {
const result = validateEmailAddresses(AllowedDomains, Emails, {
treatMustacheTemplatesAsValid: true,
});
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"address": "bob@elastic.co",
"valid": true,
},
Object {
"address": "\\"Dr Tom\\" <tom@elastic.co>",
"valid": true,
},
Object {
"address": "jim@dev.elastic.co",
"valid": true,
},
Object {
"address": "rex@found.no",
"valid": true,
},
Object {
"address": "sal@alerting.dev.elastic.co",
"reason": "notAllowed",
"valid": false,
},
Object {
"address": "nancy@example.com",
"reason": "notAllowed",
"valid": false,
},
Object {
"address": "\\"Dr RFC 5322\\" <dr@rfc5322.org>",
"reason": "notAllowed",
"valid": false,
},
Object {
"address": "totally invalid",
"reason": "invalid",
"valid": false,
},
Object {
"address": "{{sneaky}}",
"valid": true,
},
]
`);
});
test('with no configured allowlist and no mustache filtering', () => {
const result = validateEmailAddresses(null, Emails);
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"address": "bob@elastic.co",
"valid": true,
},
Object {
"address": "\\"Dr Tom\\" <tom@elastic.co>",
"valid": true,
},
Object {
"address": "jim@dev.elastic.co",
"valid": true,
},
Object {
"address": "rex@found.no",
"valid": true,
},
Object {
"address": "sal@alerting.dev.elastic.co",
"valid": true,
},
Object {
"address": "nancy@example.com",
"valid": true,
},
Object {
"address": "\\"Dr RFC 5322\\" <dr@rfc5322.org>",
"valid": true,
},
Object {
"address": "totally invalid",
"valid": true,
},
Object {
"address": "{{sneaky}}",
"valid": true,
},
]
`);
});
test('with no configured allowlist and mustache filtering', () => {
const result = validateEmailAddresses(null, Emails, { treatMustacheTemplatesAsValid: true });
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"address": "bob@elastic.co",
"valid": true,
},
Object {
"address": "\\"Dr Tom\\" <tom@elastic.co>",
"valid": true,
},
Object {
"address": "jim@dev.elastic.co",
"valid": true,
},
Object {
"address": "rex@found.no",
"valid": true,
},
Object {
"address": "sal@alerting.dev.elastic.co",
"valid": true,
},
Object {
"address": "nancy@example.com",
"valid": true,
},
Object {
"address": "\\"Dr RFC 5322\\" <dr@rfc5322.org>",
"valid": true,
},
Object {
"address": "totally invalid",
"valid": true,
},
Object {
"address": "{{sneaky}}",
"valid": true,
},
]
`);
});
});
const entriesGood: ValidatedEmail[] = [
{ address: 'a', valid: true },
{ address: 'b', valid: true },
];
const entriesInvalid: ValidatedEmail[] = [
{ address: 'c', valid: false, reason: InvalidEmailReason.invalid },
{ address: 'd', valid: false, reason: InvalidEmailReason.invalid },
];
const entriesNotAllowed: ValidatedEmail[] = [
{ address: 'e', valid: false, reason: InvalidEmailReason.notAllowed },
{ address: 'f', valid: false, reason: InvalidEmailReason.notAllowed },
];
describe('invalidEmailsAsMessage()', () => {
test('with all valid entries', () => {
expect(invalidEmailsAsMessage(entriesGood)).toMatchInlineSnapshot(`undefined`);
expect(invalidEmailsAsMessage([entriesGood[0]])).toMatchInlineSnapshot(`undefined`);
});
test('with some invalid entries', () => {
let entries = entriesGood.concat(entriesInvalid);
expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(`"not valid emails: c, d"`);
entries = entriesGood.concat(entriesInvalid[0]);
expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(`"not valid emails: c"`);
});
test('with some not allowed entries', () => {
let entries = entriesGood.concat(entriesNotAllowed);
expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(`"not allowed emails: e, f"`);
entries = entriesGood.concat(entriesNotAllowed[0]);
expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(`"not allowed emails: e"`);
});
test('with some invalid and not allowed entries', () => {
const entries = entriesGood.concat(entriesInvalid).concat(entriesNotAllowed);
expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(
`"not valid emails: c, d; not allowed emails: e, f"`
);
});
});
});

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { parseAddressList } from 'email-addresses';
import { ValidatedEmail, InvalidEmailReason } from './types';
import { hasMustacheTemplate } from './mustache_template';
/** Options that can be used when validating email addresses */
export interface ValidateEmailAddressesOptions {
/** treat any address which contains a mustache template as valid */
treatMustacheTemplatesAsValid?: boolean;
}
// this can be useful for cases where a plugin needs this function,
// but the actions plugin may not be available. This could be used
// as a stub for the real implementation.
export function validateEmailAddressesAsAlwaysValid(addresses: string[]): ValidatedEmail[] {
return addresses.map((address) => ({ address, valid: true }));
}
export function validateEmailAddresses(
allowedDomains: string[] | null,
addresses: string[],
options: ValidateEmailAddressesOptions = {}
): ValidatedEmail[] {
// note: this is the legacy default, which would in theory allow
// mustache strings, so options.allowMustache is ignored in this
// case - everything is valid!
if (allowedDomains == null) {
return validateEmailAddressesAsAlwaysValid(addresses);
}
return addresses.map((address) => validateEmailAddress(allowedDomains, address, options));
}
export function invalidEmailsAsMessage(validatedEmails: ValidatedEmail[]): string | undefined {
const invalid = validatedEmails.filter(
(validated) => !validated.valid && validated.reason === InvalidEmailReason.invalid
);
const notAllowed = validatedEmails.filter(
(validated) => !validated.valid && validated.reason === InvalidEmailReason.notAllowed
);
const messages: string[] = [];
if (invalid.length !== 0) {
messages.push(`not valid emails: ${addressesFromValidatedEmails(invalid).join(', ')}`);
}
if (notAllowed.length !== 0) {
messages.push(`not allowed emails: ${addressesFromValidatedEmails(notAllowed).join(', ')}`);
}
if (messages.length === 0) return;
return messages.join('; ');
}
// in case the npm email-addresses returns unexpected things ...
function validateEmailAddress(
allowedDomains: string[],
address: string,
options: ValidateEmailAddressesOptions
): ValidatedEmail {
// The reason we bypass the validation in this case, is that email addresses
// used in an alerting action could contain mustache templates which render
// as the actual values. So we can't really validate them. Fear not!
// We always do a final validation in the executor where we do NOT
// have this flag on.
if (options.treatMustacheTemplatesAsValid && hasMustacheTemplate(address)) {
return { address, valid: true };
}
try {
return validateEmailAddress_(allowedDomains, address);
} catch (err) {
return { address, valid: false, reason: InvalidEmailReason.invalid };
}
}
function validateEmailAddress_(allowedDomains: string[], address: string): ValidatedEmail {
const emailAddresses = parseAddressList(address);
if (emailAddresses == null) {
return { address, valid: false, reason: InvalidEmailReason.invalid };
}
const allowedDomainsSet = new Set(allowedDomains);
for (const emailAddress of emailAddresses) {
let domains: string[] = [];
if (emailAddress.type === 'group') {
domains = emailAddress.addresses.map((groupAddress) => groupAddress.domain);
} else if (emailAddress.type === 'mailbox') {
domains = [emailAddress.domain];
} else {
return { address, valid: false, reason: InvalidEmailReason.invalid };
}
for (const domain of domains) {
if (!allowedDomainsSet.has(domain)) {
return { address, valid: false, reason: InvalidEmailReason.notAllowed };
}
}
}
return { address, valid: true };
}
function addressesFromValidatedEmails(validatedEmails: ValidatedEmail[]) {
return validatedEmails.map((validatedEmail) => validatedEmail.address);
}

View file

@ -10,5 +10,6 @@
"configPath": ["xpack", "actions"],
"requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "eventLog", "features"],
"optionalPlugins": ["usageCollection", "spaces", "security"],
"ui": false
"extraPublicDirs": ["common"],
"ui": true
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginInitializerContext } from 'src/core/public';
import { Plugin, ActionsPublicPluginSetup } from './plugin';
export type { ActionsPublicPluginSetup };
export { Plugin };
export function plugin(context: PluginInitializerContext) {
return new Plugin(context);
}

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from 'src/core/public/mocks';
import { Plugin } from './plugin';
describe('Actions Plugin', () => {
describe('setup()', () => {
const emails = ['bob@elastic.co', 'jim@somewhere.org', 'not an email'];
it('should allow all emails when not using email allowlist config', async () => {
const context = coreMock.createPluginInitializerContext({});
const plugin = new Plugin(context);
const pluginSetup = plugin.setup();
const validated = pluginSetup.validateEmailAddresses(emails);
expect(validated).toMatchInlineSnapshot(`
Array [
Object {
"address": "bob@elastic.co",
"valid": true,
},
Object {
"address": "jim@somewhere.org",
"valid": true,
},
Object {
"address": "not an email",
"valid": true,
},
]
`);
});
it('should validate correctly when using email allowlist config', async () => {
const context = coreMock.createPluginInitializerContext({
email: { domain_allowlist: ['elastic.co'] },
});
const plugin = new Plugin(context);
const pluginSetup = plugin.setup();
const validated = pluginSetup.validateEmailAddresses(emails);
expect(validated).toMatchInlineSnapshot(`
Array [
Object {
"address": "bob@elastic.co",
"valid": true,
},
Object {
"address": "jim@somewhere.org",
"reason": "notAllowed",
"valid": false,
},
Object {
"address": "not an email",
"reason": "invalid",
"valid": false,
},
]
`);
});
});
});

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Plugin as CorePlugin, PluginInitializerContext } from 'src/core/public';
import {
ValidatedEmail,
validateEmailAddresses as validateEmails,
ValidateEmailAddressesOptions,
} from '../common';
export interface ActionsPublicPluginSetup {
validateEmailAddresses(
emails: string[],
options?: ValidateEmailAddressesOptions
): ValidatedEmail[];
}
export interface Config {
email: {
domain_allowlist: string[];
};
}
export class Plugin implements CorePlugin<ActionsPublicPluginSetup> {
private readonly allowedEmailDomains: string[] | null = null;
constructor(ctx: PluginInitializerContext<Config>) {
const config = ctx.config.get();
this.allowedEmailDomains = config.email?.domain_allowlist || null;
}
public setup(): ActionsPublicPluginSetup {
return {
validateEmailAddresses: (emails: string[], options: ValidateEmailAddressesOptions) =>
validateEmails(this.allowedEmailDomains, emails, options),
};
}
public start(): void {}
}

View file

@ -25,6 +25,7 @@ const createActionsConfigMock = () => {
}),
getCustomHostSettings: jest.fn().mockReturnValue(undefined),
getMicrosoftGraphApiUrl: jest.fn().mockReturnValue(undefined),
validateEmailAddresses: jest.fn().mockReturnValue(undefined),
};
return mocked;
};

View file

@ -486,3 +486,47 @@ describe('getSSLSettings', () => {
expect(sslSettings.verificationMode).toBe('none');
});
});
const testEmailsOk = ['bob@elastic.co', 'jim@elastic.co'];
const testEmailsNotAllowed = ['hal@bad.com', 'lou@notgood.org'];
const testEmailsInvalid = ['invalid-email-address', '(garbage)'];
const testEmailsAll = testEmailsOk.concat(testEmailsNotAllowed).concat(testEmailsInvalid);
describe('validateEmailAddresses()', () => {
test('all domains allowed if config not set', () => {
const acu = getActionsConfigurationUtilities(defaultActionsConfig);
const message = acu.validateEmailAddresses(testEmailsAll);
expect(message).toEqual(undefined);
});
test('only filtered domains allowed if config set', () => {
const acu = getActionsConfigurationUtilities({
...defaultActionsConfig,
email: {
domain_allowlist: ['elastic.co'],
},
});
let message = acu.validateEmailAddresses(testEmailsOk);
expect(message).toBe(undefined);
message = acu.validateEmailAddresses(testEmailsAll);
expect(message).toMatchInlineSnapshot(
`"not valid emails: invalid-email-address, (garbage); not allowed emails: hal@bad.com, lou@notgood.org"`
);
});
test('no domains allowed if config set to empty array', () => {
const acu = getActionsConfigurationUtilities({
...defaultActionsConfig,
email: {
domain_allowlist: [],
},
});
const message = acu.validateEmailAddresses(testEmailsAll);
expect(message).toMatchInlineSnapshot(
`"not valid emails: invalid-email-address, (garbage); not allowed emails: bob@elastic.co, jim@elastic.co, hal@bad.com, lou@notgood.org"`
);
});
});

View file

@ -16,7 +16,11 @@ import { getCanonicalCustomHostUrl } from './lib/custom_host_settings';
import { ActionTypeDisabledError } from './lib';
import { ProxySettings, ResponseSettings, SSLSettings } from './types';
import { getSSLSettingsFromConfig } from './builtin_action_types/lib/get_node_ssl_options';
import {
ValidateEmailAddressesOptions,
validateEmailAddresses,
invalidEmailsAsMessage,
} from '../common';
export { AllowedHosts, EnabledActionTypes } from './config';
enum AllowListingField {
@ -36,6 +40,10 @@ export interface ActionsConfigurationUtilities {
getResponseSettings: () => ResponseSettings;
getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined;
getMicrosoftGraphApiUrl: () => undefined | string;
validateEmailAddresses(
addresses: string[],
options?: ValidateEmailAddressesOptions
): string | undefined;
}
function allowListErrorMessage(field: AllowListingField, value: string) {
@ -139,12 +147,26 @@ function getCustomHostSettings(
return customHostSettings.find((settings) => settings.url === canonicalUrl);
}
function validateEmails(
config: ActionsConfig,
addresses: string[],
options: ValidateEmailAddressesOptions
): string | undefined {
if (config.email == null) {
return;
}
const validated = validateEmailAddresses(config.email.domain_allowlist, addresses, options);
return invalidEmailsAsMessage(validated);
}
export function getActionsConfigurationUtilities(
config: ActionsConfig
): ActionsConfigurationUtilities {
const isHostnameAllowed = curry(isAllowed)(config);
const isUriAllowed = curry(isHostnameAllowedInUri)(config);
const isActionTypeEnabled = curry(isActionTypeEnabledInConfig)(config);
const validatedEmailCurried = curry(validateEmails)(config);
return {
isHostnameAllowed,
isUriAllowed,
@ -170,5 +192,7 @@ export function getActionsConfigurationUtilities(
},
getCustomHostSettings: (targetUrl: string) => getCustomHostSettings(config, targetUrl),
getMicrosoftGraphApiUrl: () => getMicrosoftGraphApiUrlFromConfig(config),
validateEmailAddresses: (addresses: string[], options: ValidateEmailAddressesOptions) =>
validatedEmailCurried(addresses, options),
};
}

View file

@ -24,6 +24,7 @@ import {
EmailActionType,
EmailActionTypeExecutorOptions,
} from './email';
import { ValidateEmailAddressesOptions } from '../../common';
const sendEmailMock = sendEmail as jest.Mock;
@ -269,6 +270,26 @@ describe('config validation', () => {
`"error validating action type config: [host] value 'smtp.gmail.com' is not in the allowedHosts configuration"`
);
});
test('config validation for emails calls validateEmailAddresses', async () => {
const configurationUtilities = actionsConfigMock.create();
configurationUtilities.validateEmailAddresses.mockImplementation(validateEmailAddressesImpl);
const basicActionType = getActionType({
logger: mockedLogger,
configurationUtilities,
});
expect(() => {
validateConfig(basicActionType, {
from: 'badmail',
service: 'gmail',
});
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type config: [from]: stub for actual message"`
);
expect(configurationUtilities.validateEmailAddresses).toHaveBeenNthCalledWith(1, ['badmail']);
});
});
describe('secrets validation', () => {
@ -335,6 +356,33 @@ describe('params validation', () => {
`"error validating action params: [subject]: expected value of type [string] but got [undefined]"`
);
});
test('params validation for emails calls validateEmailAddresses', async () => {
const configurationUtilities = actionsConfigMock.create();
configurationUtilities.validateEmailAddresses.mockImplementation(validateEmailAddressesImpl);
const basicActionType = getActionType({
logger: mockedLogger,
configurationUtilities,
});
expect(() => {
validateParams(basicActionType, {
to: ['to@example.com'],
cc: ['cc@example.com'],
bcc: ['bcc@example.com'],
subject: 'this is a test',
message: 'this is the message',
});
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action params: [to/cc/bcc]: stub for actual message"`
);
const allEmails = ['to@example.com', 'cc@example.com', 'bcc@example.com'];
expect(configurationUtilities.validateEmailAddresses).toHaveBeenNthCalledWith(1, allEmails, {
treatMustacheTemplatesAsValid: true,
});
});
});
describe('execute()', () => {
@ -385,21 +433,9 @@ describe('execute()', () => {
"status": "ok",
}
`);
delete sendEmailMock.mock.calls[0][1].configurationUtilities;
expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(`
Object {
"configurationUtilities": Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction],
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
"content": Object {
"message": "a message to you
@ -447,21 +483,9 @@ describe('execute()', () => {
sendEmailMock.mockReset();
await actionType.executor(customExecutorOptions);
delete sendEmailMock.mock.calls[0][1].configurationUtilities;
expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(`
Object {
"configurationUtilities": Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction],
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
"content": Object {
"message": "a message to you
@ -509,21 +533,9 @@ describe('execute()', () => {
sendEmailMock.mockReset();
await actionType.executor(customExecutorOptions);
delete sendEmailMock.mock.calls[0][1].configurationUtilities;
expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(`
Object {
"configurationUtilities": Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction],
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
"content": Object {
"message": "a message to you
@ -673,4 +685,48 @@ describe('execute()', () => {
This message was sent by Kibana. [View this in Kibana](https://localhost:1234/foo/bar/my/app)."
`);
});
test('ensure execution runs validator with allowMustache false', async () => {
const configurationUtilities = actionsConfigMock.create();
configurationUtilities.validateEmailAddresses.mockImplementation(validateEmailAddressesImpl);
const testActionType = getActionType({
logger: mockedLogger,
configurationUtilities,
});
const customExecutorOptions: EmailActionTypeExecutorOptions = {
...executorOptions,
params: {
...params,
},
};
const result = await testActionType.executor(customExecutorOptions);
expect(result).toMatchInlineSnapshot(`
Object {
"actionId": "some-id",
"message": "[to/cc/bcc]: stub for actual message",
"status": "error",
}
`);
expect(configurationUtilities.validateEmailAddresses.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Array [
"jim@example.com",
"james@example.com",
"jimmy@example.com",
],
],
]
`);
});
});
function validateEmailAddressesImpl(
addresses: string[],
options?: ValidateEmailAddressesOptions
): string | undefined {
return 'stub for actual message';
}

View file

@ -10,10 +10,11 @@ import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import nodemailerGetService from 'nodemailer/lib/well-known';
import SMTPConnection from 'nodemailer/lib/smtp-connection';
import { Logger } from '../../../../../src/core/server';
import { withoutMustacheTemplate } from '../../common';
import { sendEmail, JSON_TRANSPORT_SERVICE, SendEmailOptions, Transport } from './lib/send_email';
import { portSchema } from './lib/schemas';
import { Logger } from '../../../../../src/core/server';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
import { ActionsConfigurationUtilities } from '../actions_config';
import { renderMustacheString, renderMustacheObject } from '../lib/mustache_renderer';
@ -65,6 +66,12 @@ function validateConfig(
): string | void {
const config = configObject;
const emails = [config.from];
const invalidEmailsMessage = configurationUtilities.validateEmailAddresses(emails);
if (!!invalidEmailsMessage) {
return `[from]: ${invalidEmailsMessage}`;
}
// If service is set as JSON_TRANSPORT_SERVICE or EXCHANGE, host/port are ignored, when the email is sent.
// Note, not currently making these message translated, as will be
// emitted alongside messages from @kbn/config-schema, which does not
@ -126,30 +133,30 @@ const SecretsSchema = schema.object({
export type ActionParamsType = TypeOf<typeof ParamsSchema>;
const ParamsSchema = schema.object(
{
to: schema.arrayOf(schema.string(), { defaultValue: [] }),
cc: schema.arrayOf(schema.string(), { defaultValue: [] }),
bcc: schema.arrayOf(schema.string(), { defaultValue: [] }),
subject: schema.string(),
message: schema.string(),
// kibanaFooterLink isn't inteded for users to set, this is here to be able to programatically
// provide a more contextual URL in the footer (ex: URL to the alert details page)
kibanaFooterLink: schema.object({
path: schema.string({ defaultValue: '/' }),
text: schema.string({
defaultValue: i18n.translate('xpack.actions.builtin.email.kibanaFooterLinkText', {
defaultMessage: 'Go to Kibana',
}),
const ParamsSchemaProps = {
to: schema.arrayOf(schema.string(), { defaultValue: [] }),
cc: schema.arrayOf(schema.string(), { defaultValue: [] }),
bcc: schema.arrayOf(schema.string(), { defaultValue: [] }),
subject: schema.string(),
message: schema.string(),
// kibanaFooterLink isn't inteded for users to set, this is here to be able to programatically
// provide a more contextual URL in the footer (ex: URL to the alert details page)
kibanaFooterLink: schema.object({
path: schema.string({ defaultValue: '/' }),
text: schema.string({
defaultValue: i18n.translate('xpack.actions.builtin.email.kibanaFooterLinkText', {
defaultMessage: 'Go to Kibana',
}),
}),
},
{
validate: validateParams,
}
);
}),
};
function validateParams(paramsObject: unknown): string | void {
const ParamsSchema = schema.object(ParamsSchemaProps);
function validateParams(
configurationUtilities: ActionsConfigurationUtilities,
paramsObject: unknown
): string | void {
// avoids circular reference ...
const params = paramsObject as ActionParamsType;
@ -159,6 +166,14 @@ function validateParams(paramsObject: unknown): string | void {
if (addrs === 0) {
return 'no [to], [cc], or [bcc] entries';
}
const emails = withoutMustacheTemplate(to.concat(cc).concat(bcc));
const invalidEmailsMessage = configurationUtilities.validateEmailAddresses(emails, {
treatMustacheTemplatesAsValid: true,
});
if (invalidEmailsMessage) {
return `[to/cc/bcc]: ${invalidEmailsMessage}`;
}
}
interface GetActionTypeParams {
@ -182,7 +197,9 @@ export function getActionType(params: GetActionTypeParams): EmailActionType {
validate: curry(validateConfig)(configurationUtilities),
}),
secrets: SecretsSchema,
params: ParamsSchema,
params: schema.object(ParamsSchemaProps, {
validate: curry(validateParams)(configurationUtilities),
}),
},
renderParameterTemplates,
executor: curry(executor)({ logger, publicBaseUrl, configurationUtilities }),
@ -220,6 +237,17 @@ async function executor(
const secrets = execOptions.secrets;
const params = execOptions.params;
const emails = params.to.concat(params.cc).concat(params.bcc);
let invalidEmailsMessage = configurationUtilities.validateEmailAddresses(emails);
if (invalidEmailsMessage) {
return { status: 'error', actionId, message: `[to/cc/bcc]: ${invalidEmailsMessage}` };
}
invalidEmailsMessage = configurationUtilities.validateEmailAddresses([config.from]);
if (invalidEmailsMessage) {
return { status: 'error', actionId, message: `[from]: ${invalidEmailsMessage}` };
}
const transport: Transport = {};
if (secrets.user != null) {

View file

@ -103,6 +103,7 @@ describe('send_email module', () => {
});
await sendEmail(mockLogger, sendEmailOptions);
requestOAuthClientCredentialsTokenMock.mock.calls[0].pop();
expect(requestOAuthClientCredentialsTokenMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"https://login.microsoftonline.com/undefined/oauth2/v2.0/token",
@ -122,32 +123,11 @@ describe('send_email module', () => {
"clientSecret": "sdfhkdsjhfksdjfh",
"scope": "https://graph.microsoft.com/.default",
},
Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
]
`);
delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities;
sendEmailGraphApiMock.mock.calls[0].pop();
expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
@ -159,29 +139,6 @@ describe('send_email module', () => {
"messageHTML": "<p>a message</p>
",
"options": Object {
"configurationUtilities": Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
"content": Object {
"message": "a message",
"subject": "a subject",
@ -218,29 +175,6 @@ describe('send_email module', () => {
"trace": [MockFunction],
"warn": [MockFunction],
},
Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
]
`);
});

View file

@ -26,6 +26,9 @@ import {
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { Logger } from '../../../../../../src/core/server';
import { SNProductsConfigValue } from '../../../common';
export type { SNProductsConfigValue, SNProductsConfig } from '../../../common';
export type ServiceNowPublicConfigurationBaseType = TypeOf<
typeof ExternalIncidentServiceConfigurationBaseSchema
@ -247,17 +250,6 @@ export interface GetApplicationInfoResponse {
version: string;
}
export interface SNProductsConfigValue {
table: string;
appScope: string;
useImportAPI: boolean;
importSetTable: string;
commentFieldKey: string;
appId?: string;
}
export type SNProductsConfig = Record<string, SNProductsConfigValue>;
export enum ObservableTypes {
ip4 = 'ipv4-addr',
url = 'URL',

View file

@ -160,22 +160,10 @@ describe('execute()', () => {
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
});
delete requestMock.mock.calls[0][0].configurationUtilities;
expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"axios": undefined,
"configurationUtilities": Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction],
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
"data": Object {
"text": "this invocation should succeed",
},
@ -225,22 +213,10 @@ describe('execute()', () => {
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
});
delete requestMock.mock.calls[0][0].configurationUtilities;
expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"axios": undefined,
"configurationUtilities": Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction],
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
"data": Object {
"text": "this invocation should succeed",
},

View file

@ -279,6 +279,7 @@ describe('execute()', () => {
params: { body: 'some data' },
});
delete requestMock.mock.calls[0][0].configurationUtilities;
expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"auth": Object {
@ -286,19 +287,6 @@ describe('execute()', () => {
"username": "abc",
},
"axios": undefined,
"configurationUtilities": Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction],
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
"data": "some data",
"headers": Object {
"aheader": "a value",
@ -377,22 +365,10 @@ describe('execute()', () => {
params: { body: 'some data' },
});
delete requestMock.mock.calls[0][0].configurationUtilities;
expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"axios": undefined,
"configurationUtilities": Object {
"ensureActionTypeEnabled": [MockFunction],
"ensureHostnameAllowed": [MockFunction],
"ensureUriAllowed": [MockFunction],
"getCustomHostSettings": [MockFunction],
"getMicrosoftGraphApiUrl": [MockFunction],
"getProxySettings": [MockFunction],
"getResponseSettings": [MockFunction],
"getSSLSettings": [MockFunction],
"isActionTypeEnabled": [MockFunction],
"isHostnameAllowed": [MockFunction],
"isUriAllowed": [MockFunction],
},
"data": "some data",
"headers": Object {
"aheader": "a value",

View file

@ -215,6 +215,25 @@ describe('config validation', () => {
}
`);
});
test('validates email.domain_allowlist', () => {
const config: Record<string, unknown> = {};
let result = configSchema.validate(config);
expect(result.email === undefined);
config.email = {};
expect(() => configSchema.validate(config)).toThrowErrorMatchingInlineSnapshot(
`"[email.domain_allowlist]: expected value of type [array] but got [undefined]"`
);
config.email = { domain_allowlist: [] };
result = configSchema.validate(config);
expect(result.email?.domain_allowlist).toEqual([]);
config.email = { domain_allowlist: ['a.com', 'b.c.com', 'd.e.f.com'] };
result = configSchema.validate(config);
expect(result.email?.domain_allowlist).toEqual(['a.com', 'b.c.com', 'd.e.f.com']);
});
});
// object creator that ensures we can create a property named __proto__ on an

View file

@ -113,6 +113,11 @@ export const configSchema = schema.object({
pageSize: schema.number({ defaultValue: 100 }),
}),
microsoftGraphApiUrl: schema.maybe(schema.string()),
email: schema.maybe(
schema.object({
domain_allowlist: schema.arrayOf(schema.string()),
})
),
});
export type ActionsConfig = TypeOf<typeof configSchema>;

View file

@ -58,6 +58,9 @@ export const plugin = (initContext: PluginInitializerContext) => new ActionsPlug
export const config: PluginConfigDescriptor<ActionsConfig> = {
schema: configSchema,
exposeToBrowser: {
email: { domain_allowlist: true },
},
deprecations: ({ renameFromRoot, unused }) => [
renameFromRoot('xpack.actions.whitelistedHosts', 'xpack.actions.allowedHosts', {
level: 'warning',

View file

@ -272,6 +272,7 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
defineRoutes(
core.http.createRouter<ActionsRequestHandlerContext>(),
this.licenseState,
actionsConfigUtils,
this.usageCounter
);

View file

@ -18,10 +18,12 @@ import { connectorTypesRoute } from './connector_types';
import { updateActionRoute } from './update';
import { getWellKnownEmailServiceRoute } from './get_well_known_email_service';
import { defineLegacyRoutes } from './legacy';
import { ActionsConfigurationUtilities } from '../actions_config';
export function defineRoutes(
router: IRouter<ActionsRequestHandlerContext>,
licenseState: ILicenseState,
actionsConfigUtils: ActionsConfigurationUtilities,
usageCounter?: UsageCounter
) {
defineLegacyRoutes(router, licenseState, usageCounter);

View file

@ -10,7 +10,8 @@
"server/**/*",
// have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636
"server/**/*.json",
"common/*"
"public/**/*",
"common/**/*"
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },

View file

@ -8,8 +8,8 @@
"server": true,
"ui": true,
"optionalPlugins": ["alerting", "cloud", "features", "home", "spaces"],
"requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects"],
"requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects", "actions"],
"configPath": ["xpack", "trigger_actions_ui"],
"extraPublicDirs": ["public/common", "public/common/constants"],
"requiredBundles": ["home", "alerting", "esUiShared", "kibanaReact", "kibanaUtils"]
"requiredBundles": ["home", "alerting", "esUiShared", "kibanaReact", "kibanaUtils", "actions"]
}

View file

@ -10,13 +10,44 @@ import { registerBuiltInActionTypes } from '../index';
import { ActionTypeModel } from '../../../../types';
import { EmailActionConnector } from '../types';
import { getEmailServices } from './email';
import {
ValidatedEmail,
InvalidEmailReason,
ValidateEmailAddressesOptions,
MustacheInEmailRegExp,
} from '../../../../../../actions/common';
const ACTION_TYPE_ID = '.email';
let actionTypeModel: ActionTypeModel;
const RegistrationServices = {
validateEmailAddresses: validateEmails,
};
// stub for the real validator
function validateEmails(
addresses: string[],
options?: ValidateEmailAddressesOptions
): ValidatedEmail[] {
return addresses.map((address) => {
if (address.includes('invalid'))
return { address, valid: false, reason: InvalidEmailReason.invalid };
else if (address.includes('notallowed'))
return { address, valid: false, reason: InvalidEmailReason.notAllowed };
else if (options?.treatMustacheTemplatesAsValid) return { address, valid: true };
else if (address.match(MustacheInEmailRegExp))
return { address, valid: false, reason: InvalidEmailReason.invalid };
else return { address, valid: true };
});
}
beforeEach(() => {
jest.resetAllMocks();
});
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: RegistrationServices });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;
@ -136,7 +167,7 @@ describe('connector validation', () => {
actionTypeId: '.email',
name: 'email',
config: {
from: 'test@test.com',
from: 'test@notallowed.com',
hasAuth: true,
service: 'other',
},
@ -145,7 +176,7 @@ describe('connector validation', () => {
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
from: ['Email address test@notallowed.com is not allowed.'],
port: ['Port is required.'],
host: ['Host is required.'],
service: [],
@ -161,7 +192,13 @@ describe('connector validation', () => {
},
},
});
// also check that mustache is not valid
actionConnector.config.from = '{{mustached}}';
const validation = await actionTypeModel.validateConnector(actionConnector);
expect(validation?.config?.errors?.from).toEqual(['Email address {{mustached}} is not valid.']);
});
test('connector validation fails when user specified but not password', async () => {
const actionConnector = {
secrets: {
@ -330,6 +367,7 @@ describe('action params validation', () => {
const actionParams = {
to: [],
cc: ['test1@test.com'],
bcc: ['mustache {{\n}} template'],
message: 'message {test}',
subject: 'test',
};
@ -347,15 +385,17 @@ describe('action params validation', () => {
test('action params validation fails when action params is not valid', async () => {
const actionParams = {
to: ['test@test.com'],
to: ['invalid.com'],
cc: ['bob@notallowed.com'],
bcc: ['another-invalid.com'],
subject: 'test',
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
to: [],
cc: [],
bcc: [],
to: ['Email address invalid.com is not valid.'],
cc: ['Email address bob@notallowed.com is not allowed.'],
bcc: ['Email address another-invalid.com is not valid.'],
message: ['Message is required.'],
subject: [],
},

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { uniq } from 'lodash';
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSelectOption } from '@elastic/eui';
@ -14,7 +15,8 @@ import {
GenericValidationResult,
} from '../../../../types';
import { EmailActionParams, EmailConfig, EmailSecrets, EmailActionConnector } from '../types';
import { AdditionalEmailServices } from '../../../../../../actions/common';
import { AdditionalEmailServices, InvalidEmailReason } from '../../../../../../actions/common';
import { RegistrationServices } from '..';
const emailServices: EuiSelectOption[] = [
{
@ -79,8 +81,9 @@ export function getEmailServices(isCloudEnabled: boolean) {
: emailServices.filter((service) => service.value !== 'elastic_cloud');
}
export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, EmailActionParams> {
const mailformat = /^[^@\s]+@[^@\s]+$/;
export function getActionType(
services: RegistrationServices
): ActionTypeModel<EmailConfig, EmailSecrets, EmailActionParams> {
return {
id: '.email',
iconClass: 'email',
@ -122,9 +125,15 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
};
if (!action.config.from) {
configErrors.from.push(translations.SENDER_REQUIRED);
}
if (action.config.from && !action.config.from.trim().match(mailformat)) {
configErrors.from.push(translations.SENDER_NOT_VALID);
} else {
const validatedEmail = services.validateEmailAddresses([action.config.from])[0];
if (!validatedEmail.valid) {
const message =
validatedEmail.reason === InvalidEmailReason.notAllowed
? translations.getNotAllowedEmailAddress(action.config.from)
: translations.getInvalidEmailAddress(action.config.from);
configErrors.from.push(message);
}
}
if (action.config.service !== AdditionalEmailServices.EXCHANGE) {
if (!action.config.port) {
@ -180,25 +189,66 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
subject: new Array<string>(),
};
const validationResult = { errors };
if (
(!(actionParams.to instanceof Array) || actionParams.to.length === 0) &&
(!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) &&
(!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0)
) {
const errorText = translations.TO_CC_REQUIRED;
errors.to.push(errorText);
errors.cc.push(errorText);
errors.bcc.push(errorText);
}
if (!actionParams.message?.length) {
errors.message.push(translations.MESSAGE_REQUIRED);
}
if (!actionParams.subject?.length) {
errors.subject.push(translations.SUBJECT_REQUIRED);
}
const toEmails = getToFields(actionParams);
const ccEmails = getCcFields(actionParams);
const bccEmails = getBccFields(actionParams);
if (toEmails.length === 0 && ccEmails.length === 0 && bccEmails.length === 0) {
const errorText = translations.TO_CC_REQUIRED;
errors.to.push(errorText);
errors.cc.push(errorText);
errors.bcc.push(errorText);
}
const allEmails = uniq(toEmails.concat(ccEmails).concat(bccEmails));
const validatedEmails = services.validateEmailAddresses(allEmails, {
treatMustacheTemplatesAsValid: true,
});
const toEmailSet = new Set(toEmails);
const ccEmailSet = new Set(ccEmails);
const bccEmailSet = new Set(bccEmails);
for (const validated of validatedEmails) {
if (!validated.valid) {
const email = validated.address;
const message =
validated.reason === InvalidEmailReason.notAllowed
? translations.getNotAllowedEmailAddress(email)
: translations.getInvalidEmailAddress(email);
if (toEmailSet.has(email)) errors.to.push(message);
if (ccEmailSet.has(email)) errors.cc.push(message);
if (bccEmailSet.has(email)) errors.bcc.push(message);
}
}
return validationResult;
},
actionConnectorFields: lazy(() => import('./email_connector')),
actionParamsFields: lazy(() => import('./email_params')),
};
}
function getToFields(actionParams: EmailActionParams): string[] {
if (!(actionParams.to instanceof Array)) return [];
return actionParams.to;
}
function getCcFields(actionParams: EmailActionParams): string[] {
if (!(actionParams.cc instanceof Array)) return [];
return actionParams.cc;
}
function getBccFields(actionParams: EmailActionParams): string[] {
if (!(actionParams.bcc instanceof Array)) return [];
return actionParams.bcc;
}

View file

@ -104,3 +104,20 @@ export const SUBJECT_REQUIRED = i18n.translate(
defaultMessage: 'Subject is required.',
}
);
export function getInvalidEmailAddress(email: string) {
return i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.invalidEmail',
{
defaultMessage: 'Email address {email} is not valid.',
values: { email },
}
);
}
export function getNotAllowedEmailAddress(email: string) {
return i18n.translate('xpack.triggersActionsUI.components.builtinActionTypes.error.notAllowed', {
defaultMessage: 'Email address {email} is not allowed.',
values: { email },
});
}

View file

@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '../index';
import { ActionTypeModel } from '../../../../types';
import { EsIndexActionConnector } from '../types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.index';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ValidatedEmail, ValidateEmailAddressesOptions } from '../../../../../actions/common';
import { getServerLogActionType } from './server_log';
import { getSlackActionType } from './slack';
import { getEmailActionType } from './email';
@ -23,14 +24,23 @@ import { getJiraActionType } from './jira';
import { getResilientActionType } from './resilient';
import { getTeamsActionType } from './teams';
export interface RegistrationServices {
validateEmailAddresses: (
addresses: string[],
options?: ValidateEmailAddressesOptions
) => ValidatedEmail[];
}
export function registerBuiltInActionTypes({
actionTypeRegistry,
services,
}: {
actionTypeRegistry: TypeRegistry<ActionTypeModel>;
services: RegistrationServices;
}) {
actionTypeRegistry.register(getServerLogActionType());
actionTypeRegistry.register(getSlackActionType());
actionTypeRegistry.register(getEmailActionType());
actionTypeRegistry.register(getEmailActionType(services));
actionTypeRegistry.register(getIndexActionType());
actionTypeRegistry.register(getPagerDutyActionType());
actionTypeRegistry.register(getSwimlaneActionType());

View file

@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { JiraActionConnector } from './types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.jira';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -8,14 +8,15 @@
import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { PagerDutyActionConnector } from '.././types';
import { PagerDutyActionConnector } from '../types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.pagerduty';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { ResilientActionConnector } from './types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.resilient';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -8,13 +8,14 @@
import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel, UserConfiguredActionConnector } from '../../../../types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.server-log';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -7,13 +7,14 @@
import { HttpSetup } from 'kibana/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config';
import {
ActionTypeExecutorResult,
snExternalServiceConfig,
} from '../../../../../../actions/common';
import { BASE_ACTION_API_PATH } from '../../../constants';
import { API_INFO_ERROR } from './translations';
import { AppInfo, RESTApiError } from './types';
import { ConnectorExecutorResult, rewriteResponseToCamelCase } from '../rewrite_response_body';
import { ActionTypeExecutorResult } from '../../../../../../actions/common';
import { Choice } from './types';
export async function getChoices({

View file

@ -9,6 +9,7 @@ import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { ServiceNowActionConnector } from './types';
import { registrationServicesMock } from '../../../../mocks';
const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow';
const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir';
@ -17,7 +18,7 @@ let actionTypeRegistry: TypeRegistry<ActionTypeModel>;
beforeAll(() => {
actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
});
describe('actionTypeRegistry.get() works', () => {

View file

@ -20,8 +20,7 @@ import { InstallationCallout } from './installation_callout';
import { UpdateConnector } from './update_connector';
import { updateActionConnector } from '../../../lib/action_connector_api';
import { Credentials } from './credentials';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config';
import { snExternalServiceConfig } from '../../../../../../actions/common';
const ServiceNowConnectorFields: React.FC<ActionConnectorFieldsProps<ServiceNowActionConnector>> =
({

View file

@ -28,8 +28,7 @@ import { isFieldInvalid } from './helpers';
import { ApplicationRequiredCallout } from './application_required_callout';
import { SNStoreLink } from './sn_store_button';
import { CredentialsAuth } from './credentials_auth';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config';
import { snExternalServiceConfig } from '../../../../../../actions/common';
const title = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormTitle',

View file

@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { SlackActionConnector } from '../types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.slack';
let actionTypeModel: ActionTypeModel;
beforeAll(async () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { SwimlaneActionConnector } from './types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.swimlane';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { TeamsActionConnector } from '../types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.teams';
let actionTypeModel: ActionTypeModel;
beforeAll(async () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { WebhookActionConnector } from '../types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.webhook';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import type { ValidatedEmail } from '../../actions/common';
import type { TriggersAndActionsUIPublicPluginStart } from './plugin';
import { getAddConnectorFlyoutLazy } from './common/get_add_connector_flyout';
import { getEditConnectorFlyoutLazy } from './common/get_edit_connector_flyout';
import { getAddAlertFlyoutLazy } from './common/get_add_alert_flyout';
import { getEditAlertFlyoutLazy } from './common/get_edit_alert_flyout';
import { RegistrationServices } from './application/components/builtin_action_types';
import { TypeRegistry } from './application/type_registry';
import {
ActionTypeModel,
@ -59,3 +60,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart {
export const triggersActionsUiMock = {
createStart: createStartMock,
};
function validateEmailAddresses(addresses: string[]): ValidatedEmail[] {
return addresses.map((address) => ({ address, valid: true }));
}
export const registrationServicesMock: RegistrationServices = { validateEmailAddresses };

View file

@ -26,6 +26,7 @@ import { PluginStartContract as AlertingStart } from '../../alerting/public';
import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { Storage } from '../../../../src/plugins/kibana_utils/public';
import type { SpacesPluginStart } from '../../spaces/public';
import { ActionsPublicPluginSetup } from '../../actions/public';
import { getAddConnectorFlyoutLazy } from './common/get_add_connector_flyout';
import { getEditConnectorFlyoutLazy } from './common/get_edit_connector_flyout';
@ -67,6 +68,7 @@ interface PluginsSetup {
management: ManagementSetup;
home?: HomePublicPluginSetup;
cloud?: { isCloudEnabled: boolean };
actions: ActionsPublicPluginSetup;
}
interface PluginsStart {
@ -164,6 +166,9 @@ export class Plugin
registerBuiltInActionTypes({
actionTypeRegistry: this.actionTypeRegistry,
services: {
validateEmailAddresses: plugins.actions.validateEmailAddresses,
},
});
return {

View file

@ -24,6 +24,7 @@ interface CreateTestConfigOptions {
preconfiguredAlertHistoryEsIndex?: boolean;
customizeLocalHostSsl?: boolean;
rejectUnauthorized?: boolean; // legacy
emailDomainsAllowed?: string[];
}
// test.not-enabled is specifically not enabled
@ -60,6 +61,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
preconfiguredAlertHistoryEsIndex = false,
customizeLocalHostSsl = false,
rejectUnauthorized = true, // legacy
emailDomainsAllowed = undefined,
} = options;
return async ({ readConfigFile }: FtrConfigProviderContext) => {
@ -130,6 +132,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
? [`--xpack.actions.customHostSettings=${JSON.stringify(customHostSettingsValue)}`]
: [];
const emailSettings = emailDomainsAllowed
? [`--xpack.actions.email.domain_allowlist=${JSON.stringify(emailDomainsAllowed)}`]
: [];
return {
testFiles: [require.resolve(`../${name}/tests/`)],
servers,
@ -167,6 +173,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
`--xpack.actions.ssl.verificationMode=${verificationMode}`,
...actionsProxyUrl,
...customHostSettings,
...emailSettings,
'--xpack.eventLog.logEntries=true',
'--xpack.task_manager.ephemeral_tasks.enabled=false',
`--xpack.task_manager.unsafe.exclude_task_types=${JSON.stringify([

View file

@ -7,6 +7,8 @@
import { createTestConfig } from '../common/config';
export const EmailDomainsAllowed = ['example.org', 'test.com'];
// eslint-disable-next-line import/no-default-export
export default createTestConfig('spaces_only', {
disabledPlugins: ['security'],
@ -15,4 +17,5 @@ export default createTestConfig('spaces_only', {
verificationMode: 'none',
customizeLocalHostSsl: true,
preconfiguredAlertHistoryEsIndex: true,
emailDomainsAllowed: EmailDomainsAllowed,
});

View file

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { ObjectRemover } from '../../../../common/lib';
import { EmailDomainsAllowed } from '../../../config';
const EmailDomainAllowed = EmailDomainsAllowed[EmailDomainsAllowed.length - 1];
// eslint-disable-next-line import/no-default-export
export default function emailTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const objectRemover = new ObjectRemover(supertest);
describe('email connector', () => {
afterEach(() => objectRemover.removeAll());
it('succeeds with allowed email domains', async () => {
const from = `bob@${EmailDomainAllowed}`;
const conn = await createConnector(from);
expect(conn.status).to.be(200);
const { id } = conn.body;
expect(id).to.be.a('string');
const to = EmailDomainsAllowed.map((domain) => `jeb@${domain}`).sort();
const cc = EmailDomainsAllowed.map((domain) => `jim@${domain}`).sort();
const bcc = EmailDomainsAllowed.map((domain) => `joe@${domain}`).sort();
const ccNames = cc.map((email) => `Jimmy Jack <${email}>`);
const run = await runConnector(id, to, ccNames, bcc);
expect(run.status).to.be(200);
const { status, data } = run.body || {};
expect(status).to.be('ok');
const { message } = data || {};
const { from: fromMsg } = message || {};
expect(fromMsg?.address).to.be(from);
expect(addressesFromMessage(message, 'to')).to.eql(to);
expect(addressesFromMessage(message, 'cc')).to.eql(cc);
expect(addressesFromMessage(message, 'bcc')).to.eql(bcc);
const ccNamesMsg = namesFromMessage(message, 'cc');
for (const ccName of ccNamesMsg) {
expect(ccName).to.be('Jimmy Jack');
}
});
describe('fails for invalid email domains', () => {
it('in create when invalid "from" used', async () => {
const from = `bob@not.allowed`;
const { status, body } = await createConnector(from);
expect(status).to.be(400);
const { message = 'no message returned' } = body || {};
expect(message).to.match(/not allowed emails: bob@not.allowed/);
});
it('in execute when invalid "to", "cc" or "bcc" used', async () => {
const from = `bob@${EmailDomainAllowed}`;
const conn = await createConnector(from);
expect(conn.status).to.be(200);
const { id } = conn.body || {};
expect(id).to.be.a('string');
const to = EmailDomainsAllowed.map((domain) => `jeb@${domain}`).sort();
const cc = EmailDomainsAllowed.map((domain) => `jim@${domain}`).sort();
const bcc = EmailDomainsAllowed.map((domain) => `joe@${domain}`).sort();
to.push('jeb1@not.allowed');
cc.push('jeb2@not.allowed');
bcc.push('jeb3@not.allowed');
const { status, body } = await runConnector(id, to, cc, bcc);
expect(status).to.be(200);
expect(body?.status).to.be('error');
expect(body?.message).to.match(
/not allowed emails: jeb1@not.allowed, jeb2@not.allowed, jeb3@not.allowed/
);
});
});
});
/* returns the following `body`, for the special email __json service:
{
"status": "ok",
"data": {
"envelope": {
"from": "bob@example.org",
"to": [ "jeb@example.com", ...]
},
"messageId": "<f11a4ac8-2ed6-70fe-5b09-c6c7e97fed25@example.org>",
"message": {
"from": { "address": "bob@example.org", "name": "" },
"to": [ { "address": "jeb@example.com", "name": "" }, ...],
"cc": [ ... ],
"bcc": [ ... ],
...
}
},
...
}
*/
async function createConnector(from: string): Promise<{ status: number; body: any }> {
const { status, body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: `An email connector from ${__filename}`,
connector_type_id: '.email',
config: {
service: '__json',
from,
hasAuth: true,
},
secrets: {
user: 'bob',
password: 'changeme',
},
});
if (status === 200) {
objectRemover.add('default', body.id, 'connector', 'actions');
}
return { status, body };
}
async function runConnector(
id: string,
to: string[],
cc: string[],
bcc: string[]
): Promise<{ status: number; body: any }> {
const subject = 'email-subject';
const message = 'email-message';
const { status, body } = await supertest
.post(`/api/actions/connector/${id}/_execute`)
.set('kbn-xsrf', 'foo')
.send({ params: { to, cc, bcc, subject, message } });
return { status, body };
}
}
function addressesFromMessage(message: any, which: 'to' | 'cc' | 'bcc'): string[] {
return addressFieldFromMessage(message, which, 'address');
}
function namesFromMessage(message: any, which: 'to' | 'cc' | 'bcc'): string[] {
return addressFieldFromMessage(message, which, 'name');
}
function addressFieldFromMessage(
message: any,
which1: 'to' | 'cc' | 'bcc',
which2: 'name' | 'address'
): string[] {
const result: string[] = [];
const list = message?.[which1];
if (!Array.isArray(list)) return result;
return list.map((entry) => `${entry?.[which2]}`).sort();
}

View file

@ -22,6 +22,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./execute'));
loadTestFile(require.resolve('./enqueue'));
loadTestFile(require.resolve('./builtin_action_types/email'));
loadTestFile(require.resolve('./builtin_action_types/es_index'));
loadTestFile(require.resolve('./builtin_action_types/webhook'));
loadTestFile(require.resolve('./builtin_action_types/preconfigured_alert_history_connector'));

View file

@ -12541,6 +12541,11 @@ elliptic@^6.0.0:
minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.1"
email-addresses@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/email-addresses/-/email-addresses-5.0.0.tgz#7ae9e7f58eef7d5e3e2c2c2d3ea49b78dc854fa6"
integrity sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==
emittery@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451"