[Alerting] Adds a builtin action for triggering webhooks (#43538)

Adds the ability to trigger webhooks using an action.

This feature is currently locked off while we figure out the right privileges model.
This commit is contained in:
Gidi Meir Morris 2019-08-23 19:42:25 +01:00 committed by GitHub
parent 6a9844c223
commit e8c50c0cfb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 745 additions and 19 deletions

View file

@ -10,19 +10,17 @@ import nodemailerServices from 'nodemailer/lib/well-known/services.json';
import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email';
import { nullableType } from './lib/nullable';
import { portSchema } from './lib/schemas';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
const PORT_MAX = 256 * 256 - 1;
// config definition
export type ActionTypeConfigType = TypeOf<typeof ConfigSchema>;
const ConfigSchema = schema.object(
{
service: nullableType(schema.string()),
host: nullableType(schema.string()),
port: nullableType(schema.number({ min: 1, max: PORT_MAX })),
port: nullableType(portSchema()),
secure: nullableType(schema.boolean()),
from: schema.string(),
},

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { fromNullable, Option } from 'fp-ts/lib/Option';
export function getRetryAfterIntervalFromHeaders(headers: Record<string, string>): Option<number> {
return fromNullable(headers['retry-after'])
.map(retryAfter => parseInt(retryAfter, 10))
.filter(retryAfter => !isNaN(retryAfter));
}

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// There appears to be an unexported implementation of Either in here: src/core/server/saved_objects/service/lib/repository.ts
// Which is basically the Haskel equivalent of Rust/ML/Scala's Result
// I'll reach out to other's in Kibana to see if we can merge these into one type
// eslint-disable-next-line @typescript-eslint/prefer-interface
export type Ok<T> = {
tag: 'ok';
value: T;
};
// eslint-disable-next-line @typescript-eslint/prefer-interface
export type Err<E> = {
tag: 'err';
error: E;
};
export type Result<T, E> = Ok<T> | Err<E>;
export function asOk<T>(value: T): Ok<T> {
return {
tag: 'ok',
value,
};
}
export function asErr<T>(error: T): Err<T> {
return {
tag: 'err',
error,
};
}
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.tag === 'ok';
}
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return !isOk(result);
}
export async function promiseResult<T, E>(future: Promise<T>): Promise<Result<T, E>> {
try {
return asOk(await future);
} catch (e) {
return asErr(e);
}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
const PORT_MAX = 256 * 256 - 1;
export const portSchema = () => schema.number({ min: 1, max: PORT_MAX });

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook';
import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header';
import {
ActionType,
@ -81,13 +82,9 @@ async function slackExecutor(
// special handling for rate limiting
if (status === 429) {
const retryAfterString = headers['retry-after'];
if (retryAfterString != null) {
const retryAfter = parseInt(retryAfterString, 10);
if (!isNaN(retryAfter)) {
return retryResultSeconds(id, err.message, retryAfter);
}
}
return getRetryAfterIntervalFromHeaders(headers)
.map(retry => retryResultSeconds(id, err.message, retry))
.getOrElse(retryResult(id, err.message));
}
return errorResult(id, `${err.message} - ${statusText}`);
@ -154,7 +151,7 @@ function retryResult(id: string, message: string): ActionTypeExecutorResult {
function retryResultSeconds(
id: string,
message: string,
retryAfter: number = 60
retryAfter: number
): ActionTypeExecutorResult {
const retryEpoch = Date.now() + retryAfter * 1000;
const retry = new Date(retryEpoch);

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { actionType } from './webhook';
import { validateConfig, validateSecrets, validateParams } from '../lib';
describe('actionType', () => {
test('exposes the action as `webhook` on its Id and Name', () => {
expect(actionType.id).toEqual('.webhook');
expect(actionType.name).toEqual('webhook');
});
});
describe('secrets validation', () => {
test('succeeds when secrets is valid', () => {
const secrets: Record<string, any> = {
user: 'bob',
password: 'supersecret',
};
expect(validateSecrets(actionType, secrets)).toEqual(secrets);
});
test('fails when secret password is omitted', () => {
expect(() => {
validateSecrets(actionType, { user: 'bob' });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"`
);
});
test('fails when secret user is omitted', () => {
expect(() => {
validateSecrets(actionType, {});
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"`
);
});
});
describe('config validation', () => {
const defaultValues: Record<string, any> = {
headers: null,
method: 'post',
};
test('config validation passes when only required fields are provided', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
};
expect(validateConfig(actionType, config)).toEqual({
...defaultValues,
...config,
});
});
test('config validation passes when valid methods are provided', () => {
['post', 'put'].forEach(method => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
method,
};
expect(validateConfig(actionType, config)).toEqual({
...defaultValues,
...config,
});
});
});
test('should validate and throw error when method on config is invalid', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
method: 'https',
};
expect(() => {
validateConfig(actionType, config);
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type config: [method]: types that failed validation:
- [method.0]: expected value to equal [post] but got [https]
- [method.1]: expected value to equal [put] but got [https]"
`);
});
test('config validation passes when a url is specified', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
};
expect(validateConfig(actionType, config)).toEqual({
...defaultValues,
...config,
});
});
test('config validation passes when valid headers are provided', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
headers: {
'Content-Type': 'application/json',
},
};
expect(validateConfig(actionType, config)).toEqual({
...defaultValues,
...config,
});
});
test('should validate and throw error when headers on config is invalid', () => {
const config: Record<string, any> = {
url: 'http://mylisteningserver:9200/endpoint',
headers: 'application/json',
};
expect(() => {
validateConfig(actionType, config);
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type config: [headers]: types that failed validation:
- [headers.0]: expected value of type [object] but got [string]
- [headers.1]: expected value to equal [null] but got [application/json]"
`);
});
});
describe('params validation', () => {
test('param validation passes when no fields are provided as none are required', () => {
const params: Record<string, any> = {};
expect(validateParams(actionType, params)).toEqual({});
});
test('params validation passes when a valid body is provided', () => {
const params: Record<string, any> = {
body: 'count: {{ctx.payload.hits.total}}',
};
expect(validateParams(actionType, params)).toEqual({
...params,
});
});
});

View file

@ -0,0 +1,194 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import axios, { AxiosError, AxiosResponse } from 'axios';
import { schema, TypeOf } from '@kbn/config-schema';
import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header';
import { nullableType } from './lib/nullable';
import { isOk, promiseResult, Result } from './lib/result_type';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
// config definition
enum WebhookMethods {
POST = 'post',
PUT = 'put',
}
export type ActionTypeConfigType = TypeOf<typeof ConfigSchema>;
const HeadersSchema = schema.recordOf(schema.string(), schema.string());
const ConfigSchema = schema.object({
url: schema.string(),
method: schema.oneOf([schema.literal(WebhookMethods.POST), schema.literal(WebhookMethods.PUT)], {
defaultValue: WebhookMethods.POST,
}),
headers: nullableType(HeadersSchema),
});
// secrets definition
export type ActionTypeSecretsType = TypeOf<typeof SecretsSchema>;
const SecretsSchema = schema.object({
user: schema.string(),
password: schema.string(),
});
// params definition
export type ActionParamsType = TypeOf<typeof ParamsSchema>;
const ParamsSchema = schema.object({
body: schema.maybe(schema.string()),
});
// action type definition
export const actionType: ActionType = {
id: '.webhook',
name: 'webhook',
validate: {
config: ConfigSchema,
secrets: SecretsSchema,
params: ParamsSchema,
},
executor,
};
// action executor
async function executor(execOptions: ActionTypeExecutorOptions): Promise<ActionTypeExecutorResult> {
const log = (level: string, msg: string) =>
execOptions.services.log([level, 'actions', 'webhook'], msg);
const id = execOptions.id;
const { method, url, headers = {} } = execOptions.config as ActionTypeConfigType;
const { user: username, password } = execOptions.secrets as ActionTypeSecretsType;
const { body: data } = execOptions.params as ActionParamsType;
const result: Result<AxiosResponse, AxiosError> = await promiseResult(
axios.request({
method,
url,
auth: {
username,
password,
},
headers,
data,
})
);
if (isOk(result)) {
const {
value: { status, statusText },
} = result;
log('debug', `response from ${id} webhook event: [HTTP ${status}] ${statusText}`);
return successResult(data);
} else {
const { error } = result;
if (error.response) {
const { status, statusText, headers: responseHeaders } = error.response;
const message = `[${status}] ${statusText}`;
log(`warn`, `error on ${id} webhook event: ${message}`);
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// special handling for 5xx
if (status >= 500) {
return retryResult(id, message);
}
// special handling for rate limiting
if (status === 429) {
return getRetryAfterIntervalFromHeaders(responseHeaders)
.map(retry => retryResultSeconds(id, message, retry))
.getOrElse(retryResult(id, message));
}
return errorResultInvalid(id, message);
}
const message = i18n.translate('xpack.actions.builtin.webhook.unreachableRemoteWebhook', {
defaultMessage: 'Unreachable Remote Webhook, are you sure the address is correct?',
});
log(`warn`, `error on ${id} webhook event: ${message}`);
return errorResultUnreachable(id, message);
}
}
// Action Executor Result w/ internationalisation
function successResult(data: any): ActionTypeExecutorResult {
return { status: 'ok', data };
}
function errorResultInvalid(id: string, message: string): ActionTypeExecutorResult {
const errMessage = i18n.translate('xpack.actions.builtin.webhook.invalidResponseErrorMessage', {
defaultMessage: 'an error occurred in action "{id}" calling a remote webhook: {message}',
values: {
id,
message,
},
});
return {
status: 'error',
message: errMessage,
};
}
function errorResultUnreachable(id: string, message: string): ActionTypeExecutorResult {
const errMessage = i18n.translate('xpack.actions.builtin.webhook.unreachableErrorMessage', {
defaultMessage: 'an error occurred in action "{id}" calling a remote webhook: {message}',
values: {
id,
message,
},
});
return {
status: 'error',
message: errMessage,
};
}
function retryResult(id: string, message: string): ActionTypeExecutorResult {
const errMessage = i18n.translate(
'xpack.actions.builtin.webhook.invalidResponseRetryLaterErrorMessage',
{
defaultMessage: 'an error occurred in action "{id}" calling a remote webhook, retry later',
values: {
id,
},
}
);
return {
status: 'error',
message: errMessage,
retry: true,
};
}
function retryResultSeconds(
id: string,
message: string,
retryAfter: number
): ActionTypeExecutorResult {
const retryEpoch = Date.now() + retryAfter * 1000;
const retry = new Date(retryEpoch);
const retryString = retry.toISOString();
const errMessage = i18n.translate(
'xpack.actions.builtin.webhook.invalidResponseRetryDateErrorMessage',
{
defaultMessage:
'an error occurred in action "{id}" calling a remote webhook, retry at {retryString}: {message}',
values: {
id,
retryString,
message,
},
}
);
return {
status: 'error',
message: errMessage,
retry,
};
}

View file

@ -8,10 +8,7 @@ import path from 'path';
import { CA_CERT_PATH } from '@kbn/dev-utils';
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
import { services } from './services';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from './fixtures/plugins/actions';
import { getAllExternalServiceSimulatorPaths } from './fixtures/plugins/actions';
interface CreateTestConfigOptions {
license: string;
@ -59,9 +56,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
...disabledPlugins.map(key => `--xpack.${key}.enabled=false`),
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`,
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'actions')}`,
`--server.xsrf.whitelist=${JSON.stringify([
getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK),
])}`,
`--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`,
...(ssl
? [
`--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`,

View file

@ -5,17 +5,25 @@
*/
import Hapi from 'hapi';
import { initPlugin as initSlack } from './slack_simulation';
import { initPlugin as initWebhook } from './webhook_simulation';
const NAME = 'actions-FTS-external-service-simulators';
export enum ExternalServiceSimulator {
SLACK = 'slack',
WEBHOOK = 'webhook',
}
export function getExternalServiceSimulatorPath(service: ExternalServiceSimulator): string {
return `/api/_${NAME}/${service}`;
}
export function getAllExternalServiceSimulatorPaths(): string[] {
return Object.values(ExternalServiceSimulator).map(service =>
getExternalServiceSimulatorPath(service)
);
}
// eslint-disable-next-line import/no-default-export
export default function(kibana: any) {
return new kibana.Plugin({
@ -23,6 +31,7 @@ export default function(kibana: any) {
name: NAME,
init: (server: Hapi.Server) => {
initSlack(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK));
initWebhook(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK));
},
});
}

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import Joi from 'joi';
import Hapi, { Util } from 'hapi';
import { fromNullable } from 'fp-ts/lib/Option';
interface WebhookRequest extends Hapi.Request {
payload: string;
}
export async function initPlugin(server: Hapi.Server, path: string) {
server.auth.scheme('identifyCredentialsIfPresent', function identifyCredentialsIfPresent(
s: Hapi.Server,
options?: Hapi.ServerAuthSchemeOptions
) {
const scheme = {
async authenticate(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const credentials = fromNullable(request.headers.authorization)
.map(authorization => authorization.split(/\s+/))
.filter(parts => parts.length > 1)
.map(parts => Buffer.from(parts[1], 'base64').toString())
.filter(credentialsPart => credentialsPart.indexOf(':') !== -1)
.map(credentialsPart => {
const [username, password] = credentialsPart.split(':');
return { username, password };
})
.getOrElse({ username: '', password: '' });
return h.authenticated({ credentials });
},
};
return scheme;
});
server.auth.strategy('simple', 'identifyCredentialsIfPresent');
server.route({
method: ['POST', 'PUT'],
path,
options: {
auth: 'simple',
validate: {
options: { abortEarly: false },
payload: Joi.string(),
},
},
handler: webhookHandler,
});
}
function webhookHandler(request: WebhookRequest, h: any) {
const body = request.payload;
switch (body) {
case 'success':
return htmlResponse(h, 200, `OK`);
case 'authenticate':
return validateAuthentication(request, h);
case 'success_post_method':
return validateRequestUsesMethod(request, h, 'post');
case 'success_put_method':
return validateRequestUsesMethod(request, h, 'put');
case 'faliure':
return htmlResponse(h, 500, `Error`);
}
return htmlResponse(
h,
400,
`unknown request to webhook simulator [${body ? `content: ${body}` : `no content`}]`
);
}
function validateAuthentication(request: WebhookRequest, h: any) {
const {
auth: { credentials },
} = request;
try {
expect(credentials).to.eql({
username: 'elastic',
password: 'changeme',
});
return htmlResponse(h, 200, `OK`);
} catch (ex) {
return htmlResponse(h, 403, `the validateAuthentication operation failed. ${ex.message}`);
}
}
function validateRequestUsesMethod(
request: WebhookRequest,
h: any,
method: Util.HTTP_METHODS_PARTIAL
) {
try {
expect(request.method).to.eql(method);
return htmlResponse(h, 200, `OK`);
} catch (ex) {
return htmlResponse(h, 403, `the validateAuthentication operation failed. ${ex.message}`);
}
}
function htmlResponse(h: any, code: number, text: string) {
return h
.response(text)
.type('text/html')
.code(code);
}

View file

@ -0,0 +1,203 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { URL, format as formatUrl } from 'url';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from '../../../../common/fixtures/plugins/actions';
const defaultValues: Record<string, any> = {
headers: null,
method: 'post',
};
function parsePort(url: Record<string, string>): Record<string, string | null | number> {
return {
...url,
port: url.port ? parseInt(url.port, 10) : url.port,
};
}
// eslint-disable-next-line import/no-default-export
export default function webhookTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
async function createWebhookAction(
urlWithCreds: string,
config: Record<string, string | Record<string, string>> = {}
): Promise<string> {
const { url: fullUrl, user, password } = extractCredentialsFromUrl(urlWithCreds);
const url = config.url && typeof config.url === 'object' ? parsePort(config.url) : fullUrl;
const composedConfig = {
headers: {
'Content-Type': 'text/plain',
},
...config,
url,
};
const { body: createdAction } = await supertest
.post('/api/action')
.set('kbn-xsrf', 'test')
.send({
description: 'A generic Webhook action',
actionTypeId: '.webhook',
secrets: {
user,
password,
},
config: composedConfig,
})
.expect(200);
return createdAction.id;
}
describe('webhook action', () => {
let webhookSimulatorURL: string = '<could not determine kibana url>';
// need to wait for kibanaServer to settle ...
before(() => {
const kibanaServer = getService('kibanaServer');
const kibanaUrl = kibanaServer.status && kibanaServer.status.kibanaServerUrl;
const webhookServiceUrl = getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK);
webhookSimulatorURL = `${kibanaUrl}${webhookServiceUrl}`;
});
after(() => esArchiver.unload('empty_kibana'));
it('should return 200 when creating a webhook action successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/action')
.set('kbn-xsrf', 'test')
.send({
description: 'A generic Webhook action',
actionTypeId: '.webhook',
secrets: {
user: 'username',
password: 'mypassphrase',
},
config: {
url: webhookSimulatorURL,
},
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
description: 'A generic Webhook action',
actionTypeId: '.webhook',
config: {
...defaultValues,
url: webhookSimulatorURL,
},
});
expect(typeof createdAction.id).to.be('string');
const { body: fetchedAction } = await supertest
.get(`/api/action/${createdAction.id}`)
.expect(200);
expect(fetchedAction).to.eql({
id: fetchedAction.id,
description: 'A generic Webhook action',
actionTypeId: '.webhook',
config: {
...defaultValues,
url: webhookSimulatorURL,
},
});
});
it('should send authentication to the webhook target', async () => {
const webhookActionId = await createWebhookAction(webhookSimulatorURL);
const { body: result } = await supertest
.post(`/api/action/${webhookActionId}/_execute`)
.set('kbn-xsrf', 'test')
.send({
params: {
body: 'authenticate',
},
})
.expect(200);
expect(result.status).to.eql('ok');
});
it('should support the POST method against webhook target', async () => {
const webhookActionId = await createWebhookAction(webhookSimulatorURL, { method: 'post' });
const { body: result } = await supertest
.post(`/api/action/${webhookActionId}/_execute`)
.set('kbn-xsrf', 'test')
.send({
params: {
body: 'success_post_method',
},
})
.expect(200);
expect(result.status).to.eql('ok');
});
it('should support the PUT method against webhook target', async () => {
const webhookActionId = await createWebhookAction(webhookSimulatorURL, { method: 'put' });
const { body: result } = await supertest
.post(`/api/action/${webhookActionId}/_execute`)
.set('kbn-xsrf', 'test')
.send({
params: {
body: 'success_put_method',
},
})
.expect(200);
expect(result.status).to.eql('ok');
});
it('should handle unreachable webhook targets', async () => {
const webhookActionId = await createWebhookAction('http://some.non.existent.com/endpoint');
const { body: result } = await supertest
.post(`/api/action/${webhookActionId}/_execute`)
.set('kbn-xsrf', 'test')
.send({
params: {
body: 'failure',
},
})
.expect(200);
expect(result.status).to.eql('error');
expect(result.message).to.match(/Unreachable Remote Webhook/);
});
it('should handle failing webhook targets', async () => {
const webhookActionId = await createWebhookAction(webhookSimulatorURL);
const { body: result } = await supertest
.post(`/api/action/${webhookActionId}/_execute`)
.set('kbn-xsrf', 'test')
.send({
params: {
body: 'failure',
},
})
.expect(200);
expect(result.status).to.eql('error');
expect(result.message).to.match(/Bad Request/);
});
});
}
function extractCredentialsFromUrl(url: string): { url: string; user: string; password: string } {
const parsedUrl = new URL(url);
const { password, username: user } = parsedUrl;
return { url: formatUrl(parsedUrl, { auth: false }), user, password };
}

7
x-pack/test/typings/hapi_basic.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
declare module '@hapi/basic';