From 71a847d5cba0ccde20643fd5093a8488debc3d8f Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 26 Jun 2018 10:48:27 -0400 Subject: [PATCH] [Beats Management] Move to Ingest UI arch and initial TS effort (#20039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Beats Management] Initial scaffolding for plugin (#18977) * Initial scaffolding for Beats plugin * Removing bits not (yet) necessary in initial scaffolding * [Beats Management] Install Beats index template on plugin init (#19072) * Install Beats index template on plugin init * Adding missing files * [Beats Management] APIs: Create enrollment tokens (#19018) * WIP checkin * Register API routes * Fixing typo in index name * Adding TODOs * Removing commented out license checking code that isn't yet implemented * Remove unnecessary async/await * Don't return until indices have been refreshed * Add API integration test * Converting to Jest test * Fixing API for default case + adding test for it * Fixing copy pasta typos * Adding TODO * Fixing variable name * Using a single index * Adding expiration date field * Adding test for expiration date field * Ignore non-existent index * Fixing logic in test * Creating constant for default enrollment tokens TTL value * Updating test * Fixing name of test file (#19100) * [Beats Management] APIs: Enroll beat (#19056) * WIP checkin * Add API integration test * Converting to Jest test * Create API for enrolling a beat * Handle invalid or expired enrollment tokens * Use create instead of index to prevent same beat from being enrolled twice * Adding unit test for duplicate beat enrollment * Do not persist enrollment token with beat once token has been checked and used * Fix datatype of host_ip field * Make Kibana API guess host IP instead of requiring it in payload * Fixing error introduced in rebase conflict resolution * [Beats Management] APIs: List beats (#19086) * WIP checkin * Add API integration test * Converting to Jest test * WIP checkin * Fixing API for default case + adding test for it * Fixing copy pasta typos * Fixing variable name * Using a single index * Implementing GET /api/beats/agents API * Updating mapping * [Beats Management] APIs: Verify beats (#19103) * WIP checkin * WIP checkin * Add API integration test * Converting to Jest test * Fixing API for default case + adding test for it * Fixing copy pasta typos * Fixing variable name * Using a single index * Implementing GET /api/beats/agents API * Creating POST /api/beats/agents/verify API * Refactoring: extracting out helper functions * Fleshing out remaining tests * Expanding TODO note so I won't forget :) * Fixing file name * Updating mapping * Moving TODO comment to right file * Rename determine* helper functions to find* * Fixing assertions (#19194) * [Beats Management] APIs: Update beat (#19148) * WIP checkin * WIP checkin * Add API integration test * Converting to Jest test * Fixing API for default case + adding test for it * Fixing copy pasta typos * Fixing variable name * Using a single index * Implementing GET /api/beats/agents API * Creating POST /api/beats/agents/verify API * Refactoring: extracting out helper functions * Expanding TODO note so I won't forget :) * Fixing file name * Updating mapping * Add API tests * Update template to allow version field for beat * Implement PUT /api/beats/agent/{beat ID} API * Make enroll beat code consistent with update beat code * Fixing minor typo in TODO comment * Allow version in request payload * Make sure beat is not updated in ES in error scenarios * Adding version as required field in Enroll Beat API payload * Using destructuring * Fixing rename that was accidentally reversed in conflict fixing * [Beats Management] APIs: take auth tokens via headers (#19210) * WIP checkin * WIP checkin * Add API integration test * Converting to Jest test * Fixing API for default case + adding test for it * Fixing copy pasta typos * Fixing variable name * Using a single index * Implementing GET /api/beats/agents API * Creating POST /api/beats/agents/verify API * Refactoring: extracting out helper functions * Expanding TODO note so I won't forget :) * Fixing file name * Updating mapping * Fixing minor typo in TODO comment * Make "Enroll Beat" API take enrollment token via header instead of request body * Make "Update Beat" API take access token via header instead of request body * [Beats Management] APIs: Create configuration block (#19270) * WIP checkin * WIP checkin * Add API integration test * Converting to Jest test * Fixing API for default case + adding test for it * Fixing copy pasta typos * Fixing variable name * Using a single index * Implementing GET /api/beats/agents API * Creating POST /api/beats/agents/verify API * Refactoring: extracting out helper functions * Expanding TODO note so I won't forget :) * Fixing file name * Updating mapping * Fixing minor typo in TODO comment * Implementing POST /api/beats/configuration_blocks API * Removing unnecessary escaping * Fleshing out types + adding validation for them * Making output singular (was outputs) * Removing metricbeat.inputs * Revert implementation of `POST /api/beats/configuration_blocks` API (#19340) This API allowed the user to operate at a level of abstraction that is unnecessarily and dangerously too low. A better API would be at one level higher, where users can create, update, and delete tags (where a tag can contain multiple configuration blocks). * [Beats Management] APIs: Create or update tag (#19342) * Updating mappings * Implementing PUT /api/beats/tag/{tag} API * [Beats Management] Prevent timing attacks when checking auth tokens (#19363) * Using crypto.timingSafeEqual() for comparing auth tokens * Prevent subtler timing attack in token comparison function * Introduce random delay after we try to find token in ES to mitigate timing attack * Remove random delay * [Beats Management] APIs: Assign tag(s) to beat(s) (#19431) * Using crypto.timingSafeEqual() for comparing auth tokens * Introduce random delay after we try to find token in ES to mitigate timing attack * Rename "determine" to "find" * Remove random delay * Starting to implement POST /api/beats/beats_tags API * Changing API * Updating tests for changes to API * Updating ES archive * Renaming * Use destructuring * Moving start of script to own line to increase readability * Using destructuring * [Beats Management] APIs: Remove tag(s) from beat(s) (#19440) * Using crypto.timingSafeEqual() for comparing auth tokens * Introduce random delay after we try to find token in ES to mitigate timing attack * Remove random delay * Starting to implement POST /api/beats/beats_tags API * Changing API * Updating tests for changes to API * Renaming * Use destructuring * Using crypto.timingSafeEqual() for comparing auth tokens * Introduce random delay after we try to find token in ES to mitigate timing attack * Implementing `POST /api/beats/agents_tags/removals` API * Updating ES archive * Use destructuring * Moving start of script to own line to increase readability * Nothing to remove if there are no existing tags! * Updating tests to match changes in bulk update painless script * Use destructuring * Ported over base types and arch structure * move management of installIndexTemplate into the framework adapter * ts-lint fix * tslint fixes * more ts tweaks * fix paths * added several working endpoints * add more routes and bug fixes * fix linting * fix type remove CRUFT * remove more cruft * remove more CRUFT * added comments, change plurality * add tsconfig file * add extends path * fixed typo * serveral PR review fixes * fixed lodash type version * “fix” types by applying a lot of any --- package.json | 2 +- tsconfig.json | 5 +- x-pack/package.json | 7 +- .../common/constants/configuration_blocks.js | 19 -- .../common/constants/configuration_blocks.ts | 15 + .../common/constants/{index.js => index.ts} | 5 +- .../{index_names.js => index_names.ts} | 2 +- .../common/constants/{plugin.js => plugin.ts} | 2 +- x-pack/plugins/beats/{index.js => index.ts} | 27 +- x-pack/plugins/beats/server/kibana.index.ts | 14 + .../beats/elasticsearch_beats_adapter.ts | 218 +++++++++++++++ .../kibana/kibana_framework_adapter.ts | 82 ++++++ .../tags/elasticsearch_tags_adapter.ts | 57 ++++ .../tokens/elasticsearch_tokens_adapter.ts | 83 ++++++ .../client/call_with_internal_user_factory.js | 16 -- .../lib/client/call_with_request_factory.js | 18 -- .../plugins/beats/server/lib/client/index.js | 8 - .../beats/server/lib/compose/kibana.ts | 45 +++ .../server/lib/crypto/are_tokens_equal.js | 21 -- .../plugins/beats/server/lib/domains/beats.ts | 259 ++++++++++++++++++ .../plugins/beats/server/lib/domains/tags.ts | 90 ++++++ .../beats/server/lib/domains/tokens.ts | 80 ++++++ .../index_template/install_index_template.js | 18 -- x-pack/plugins/beats/server/lib/lib.ts | 212 ++++++++++++++ .../plugins/beats/server/management_server.ts | 30 ++ .../beats/server/rest_api/beats/enroll.ts | 63 +++++ .../beats/server/rest_api/beats/list.ts | 23 ++ .../server/rest_api/beats/tag_assignment.ts | 48 ++++ .../server/rest_api/beats/tag_removal.ts | 48 ++++ .../beats/server/rest_api/beats/update.ts | 62 +++++ .../beats/server/rest_api/beats/verify.ts | 73 +++++ .../plugins/beats/server/rest_api/tags/set.ts | 57 ++++ .../beats/server/rest_api/tokens/create.ts | 42 +++ .../plugins/beats/server/routes/api/index.js | 25 -- .../register_assign_tags_to_beats_route.js | 169 ------------ ...register_create_enrollment_tokens_route.js | 70 ----- .../routes/api/register_enroll_beat_route.js | 115 -------- .../routes/api/register_list_beats_route.js | 47 ---- .../register_remove_tags_from_beats_route.js | 166 ----------- .../routes/api/register_set_tag_route.js | 124 --------- .../routes/api/register_update_beat_route.js | 101 ------- .../routes/api/register_verify_beats_route.js | 143 ---------- x-pack/plugins/beats/server/utils/README.md | 1 + .../error_wrappers/index.ts} | 0 .../error_wrappers/wrap_es_error.test.js | 5 +- .../error_wrappers/wrap_es_error.ts} | 6 +- .../server/utils/find_non_existent_items.ts | 14 + .../index_templates}/beats_template.json | 4 +- .../index_templates/index.ts} | 3 +- .../plugins/beats/server/utils/polyfills.ts | 17 ++ .../beats/server/utils/wrap_request.ts | 24 ++ x-pack/plugins/beats/tsconfig.json | 3 + .../lib/crypto/index.js => types/json.t.ts} | 5 +- x-pack/plugins/beats/wallaby.js | 27 ++ x-pack/yarn.lock | 30 +- yarn.lock | 17 +- 56 files changed, 1769 insertions(+), 1098 deletions(-) delete mode 100644 x-pack/plugins/beats/common/constants/configuration_blocks.js create mode 100644 x-pack/plugins/beats/common/constants/configuration_blocks.ts rename x-pack/plugins/beats/common/constants/{index.js => index.ts} (76%) rename x-pack/plugins/beats/common/constants/{index_names.js => index_names.ts} (90%) rename x-pack/plugins/beats/common/constants/{plugin.js => plugin.ts} (94%) rename x-pack/plugins/beats/{index.js => index.ts} (51%) create mode 100644 x-pack/plugins/beats/server/kibana.index.ts create mode 100644 x-pack/plugins/beats/server/lib/adapters/beats/elasticsearch_beats_adapter.ts create mode 100644 x-pack/plugins/beats/server/lib/adapters/famework/kibana/kibana_framework_adapter.ts create mode 100644 x-pack/plugins/beats/server/lib/adapters/tags/elasticsearch_tags_adapter.ts create mode 100644 x-pack/plugins/beats/server/lib/adapters/tokens/elasticsearch_tokens_adapter.ts delete mode 100644 x-pack/plugins/beats/server/lib/client/call_with_internal_user_factory.js delete mode 100644 x-pack/plugins/beats/server/lib/client/call_with_request_factory.js delete mode 100644 x-pack/plugins/beats/server/lib/client/index.js create mode 100644 x-pack/plugins/beats/server/lib/compose/kibana.ts delete mode 100644 x-pack/plugins/beats/server/lib/crypto/are_tokens_equal.js create mode 100644 x-pack/plugins/beats/server/lib/domains/beats.ts create mode 100644 x-pack/plugins/beats/server/lib/domains/tags.ts create mode 100644 x-pack/plugins/beats/server/lib/domains/tokens.ts delete mode 100644 x-pack/plugins/beats/server/lib/index_template/install_index_template.js create mode 100644 x-pack/plugins/beats/server/lib/lib.ts create mode 100644 x-pack/plugins/beats/server/management_server.ts create mode 100644 x-pack/plugins/beats/server/rest_api/beats/enroll.ts create mode 100644 x-pack/plugins/beats/server/rest_api/beats/list.ts create mode 100644 x-pack/plugins/beats/server/rest_api/beats/tag_assignment.ts create mode 100644 x-pack/plugins/beats/server/rest_api/beats/tag_removal.ts create mode 100644 x-pack/plugins/beats/server/rest_api/beats/update.ts create mode 100644 x-pack/plugins/beats/server/rest_api/beats/verify.ts create mode 100644 x-pack/plugins/beats/server/rest_api/tags/set.ts create mode 100644 x-pack/plugins/beats/server/rest_api/tokens/create.ts delete mode 100644 x-pack/plugins/beats/server/routes/api/index.js delete mode 100644 x-pack/plugins/beats/server/routes/api/register_assign_tags_to_beats_route.js delete mode 100644 x-pack/plugins/beats/server/routes/api/register_create_enrollment_tokens_route.js delete mode 100644 x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js delete mode 100644 x-pack/plugins/beats/server/routes/api/register_list_beats_route.js delete mode 100644 x-pack/plugins/beats/server/routes/api/register_remove_tags_from_beats_route.js delete mode 100644 x-pack/plugins/beats/server/routes/api/register_set_tag_route.js delete mode 100644 x-pack/plugins/beats/server/routes/api/register_update_beat_route.js delete mode 100644 x-pack/plugins/beats/server/routes/api/register_verify_beats_route.js create mode 100644 x-pack/plugins/beats/server/utils/README.md rename x-pack/plugins/beats/server/{lib/error_wrappers/index.js => utils/error_wrappers/index.ts} (100%) rename x-pack/plugins/beats/server/{lib => utils}/error_wrappers/wrap_es_error.test.js (90%) rename x-pack/plugins/beats/server/{lib/error_wrappers/wrap_es_error.js => utils/error_wrappers/wrap_es_error.ts} (79%) create mode 100644 x-pack/plugins/beats/server/utils/find_non_existent_items.ts rename x-pack/plugins/beats/server/{lib/index_template => utils/index_templates}/beats_template.json (97%) rename x-pack/plugins/beats/server/{lib/index_template/index.js => utils/index_templates/index.ts} (73%) create mode 100644 x-pack/plugins/beats/server/utils/polyfills.ts create mode 100644 x-pack/plugins/beats/server/utils/wrap_request.ts create mode 100644 x-pack/plugins/beats/tsconfig.json rename x-pack/plugins/beats/{server/lib/crypto/index.js => types/json.t.ts} (77%) create mode 100644 x-pack/plugins/beats/wallaby.js diff --git a/package.json b/package.json index 48e1e3459f2fa..15178baddbe62 100644 --- a/package.json +++ b/package.json @@ -339,7 +339,7 @@ "tree-kill": "^1.1.0", "ts-jest": "^22.4.6", "ts-loader": "^3.5.0", - "ts-node": "^6.0.3", + "ts-node": "^6.1.1", "tslint": "^5.10.0", "tslint-config-prettier": "^1.12.0", "tslint-plugin-prettier": "^1.3.0", diff --git a/tsconfig.json b/tsconfig.json index dea27f75835f1..84b631b2db541 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "baseUrl": ".", + "paths": { "ui/*": ["src/ui/public/*"] }, @@ -41,7 +42,5 @@ // Disallow inconsistently-cased references to the same file. "forceConsistentCasingInFileNames": true }, - "include": [ - "src/**/*" - ] + "include": ["src/**/*"] } diff --git a/x-pack/package.json b/x-pack/package.json index b981593790761..5095d76a4f71d 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -27,8 +27,12 @@ "@kbn/es": "link:../packages/kbn-es", "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/test": "link:../packages/kbn-test", + "@types/boom": "^4.3.8", + "@types/hapi": "15.0.1", "@types/jest": "^22.2.3", - "@types/pngjs": "^3.3.1", + "@types/joi": "^10.4.0", + "@types/lodash": "^3.10.0", + "@types/pngjs": "^3.3.0", "abab": "^1.0.4", "ansicolors": "0.3.2", "aws-sdk": "2.2.33", @@ -85,6 +89,7 @@ "@kbn/ui-framework": "link:../packages/kbn-ui-framework", "@samverschueren/stream-to-observable": "^0.3.0", "@slack/client": "^4.2.2", + "@types/uuid": "^3.4.3", "angular-paging": "2.2.1", "angular-resource": "1.4.9", "angular-sanitize": "1.4.9", diff --git a/x-pack/plugins/beats/common/constants/configuration_blocks.js b/x-pack/plugins/beats/common/constants/configuration_blocks.js deleted file mode 100644 index 1818b75335f3a..0000000000000 --- a/x-pack/plugins/beats/common/constants/configuration_blocks.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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. - */ - -export const CONFIGURATION_BLOCKS = { - TYPES: { - OUTPUT: 'output', - PROCESSORS: 'processors', - FILEBEAT_INPUTS: 'filebeat.inputs', - FILEBEAT_MODULES: 'filebeat.modules', - METRICBEAT_MODULES: 'metricbeat.modules' - } -}; - -CONFIGURATION_BLOCKS.UNIQUENESS_ENFORCING_TYPES = [ - CONFIGURATION_BLOCKS.TYPES.OUTPUT -]; diff --git a/x-pack/plugins/beats/common/constants/configuration_blocks.ts b/x-pack/plugins/beats/common/constants/configuration_blocks.ts new file mode 100644 index 0000000000000..e89e53e25b89d --- /dev/null +++ b/x-pack/plugins/beats/common/constants/configuration_blocks.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum ConfigurationBlockTypes { + FilebeatInputs = 'filebeat.inputs', + FilebeatModules = 'filebeat.modules', + MetricbeatModules = 'metricbeat.modules', + Output = 'output', + Processors = 'processors', +} + +export const UNIQUENESS_ENFORCING_TYPES = [ConfigurationBlockTypes.Output]; diff --git a/x-pack/plugins/beats/common/constants/index.js b/x-pack/plugins/beats/common/constants/index.ts similarity index 76% rename from x-pack/plugins/beats/common/constants/index.js rename to x-pack/plugins/beats/common/constants/index.ts index 77c41be579c33..4662865e208a7 100644 --- a/x-pack/plugins/beats/common/constants/index.js +++ b/x-pack/plugins/beats/common/constants/index.ts @@ -6,4 +6,7 @@ export { PLUGIN } from './plugin'; export { INDEX_NAMES } from './index_names'; -export { CONFIGURATION_BLOCKS } from './configuration_blocks'; +export { + UNIQUENESS_ENFORCING_TYPES, + ConfigurationBlockTypes, +} from './configuration_blocks'; diff --git a/x-pack/plugins/beats/common/constants/index_names.js b/x-pack/plugins/beats/common/constants/index_names.ts similarity index 90% rename from x-pack/plugins/beats/common/constants/index_names.js rename to x-pack/plugins/beats/common/constants/index_names.ts index e63e8b08a6ef4..f8d20fb79c360 100644 --- a/x-pack/plugins/beats/common/constants/index_names.js +++ b/x-pack/plugins/beats/common/constants/index_names.ts @@ -5,5 +5,5 @@ */ export const INDEX_NAMES = { - BEATS: '.management-beats' + BEATS: '.management-beats', }; diff --git a/x-pack/plugins/beats/common/constants/plugin.js b/x-pack/plugins/beats/common/constants/plugin.ts similarity index 94% rename from x-pack/plugins/beats/common/constants/plugin.js rename to x-pack/plugins/beats/common/constants/plugin.ts index 289bc488c58a6..ba12300075bf2 100644 --- a/x-pack/plugins/beats/common/constants/plugin.js +++ b/x-pack/plugins/beats/common/constants/plugin.ts @@ -5,5 +5,5 @@ */ export const PLUGIN = { - ID: 'beats' + ID: 'beats', }; diff --git a/x-pack/plugins/beats/index.js b/x-pack/plugins/beats/index.ts similarity index 51% rename from x-pack/plugins/beats/index.js rename to x-pack/plugins/beats/index.ts index c105813e36ff6..ce9b8147dbe4b 100644 --- a/x-pack/plugins/beats/index.js +++ b/x-pack/plugins/beats/index.ts @@ -4,24 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { installIndexTemplate } from './server/lib/index_template'; -import { registerApiRoutes } from './server/routes/api'; +import Joi from 'joi'; import { PLUGIN } from './common/constants'; +import { initServerWithKibana } from './server/kibana.index'; const DEFAULT_ENROLLMENT_TOKENS_TTL_S = 10 * 60; // 10 minutes -export function beats(kibana) { +export function beats(kibana: any) { return new kibana.Plugin({ + config: () => + Joi.object({ + enabled: Joi.boolean().default(true), + enrollmentTokensTtlInSeconds: Joi.number() + .integer() + .min(1) + .default(DEFAULT_ENROLLMENT_TOKENS_TTL_S), + }).default(), + configPrefix: 'xpack.beats', id: PLUGIN.ID, require: ['kibana', 'elasticsearch', 'xpack_main'], - configPrefix: 'xpack.beats', - config: Joi => Joi.object({ - enabled: Joi.boolean().default(true), - enrollmentTokensTtlInSeconds: Joi.number().integer().min(1).default(DEFAULT_ENROLLMENT_TOKENS_TTL_S) - }).default(), - init: async function (server) { - await installIndexTemplate(server); - registerApiRoutes(server); - } + init(server: any) { + initServerWithKibana(server); + }, }); } diff --git a/x-pack/plugins/beats/server/kibana.index.ts b/x-pack/plugins/beats/server/kibana.index.ts new file mode 100644 index 0000000000000..c9bc9b8bf02f4 --- /dev/null +++ b/x-pack/plugins/beats/server/kibana.index.ts @@ -0,0 +1,14 @@ +/* + * 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 { Server } from 'hapi'; +import { compose } from './lib/compose/kibana'; +import { initManagementServer } from './management_server'; + +export const initServerWithKibana = (hapiServer: Server) => { + const libs = compose(hapiServer); + initManagementServer(libs); +}; diff --git a/x-pack/plugins/beats/server/lib/adapters/beats/elasticsearch_beats_adapter.ts b/x-pack/plugins/beats/server/lib/adapters/beats/elasticsearch_beats_adapter.ts new file mode 100644 index 0000000000000..283f65c1258ae --- /dev/null +++ b/x-pack/plugins/beats/server/lib/adapters/beats/elasticsearch_beats_adapter.ts @@ -0,0 +1,218 @@ +/* + * 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 { flatten, get, omit } from 'lodash'; +import moment from 'moment'; +import { INDEX_NAMES } from '../../../../common/constants'; +import { + BackendFrameworkAdapter, + CMBeat, + CMBeatsAdapter, + CMTagAssignment, + FrameworkRequest, +} from '../../lib'; + +export class ElasticsearchBeatsAdapter implements CMBeatsAdapter { + private framework: BackendFrameworkAdapter; + + constructor(framework: BackendFrameworkAdapter) { + this.framework = framework; + } + + public async get(id: string) { + const params = { + id: `beat:${id}`, + ignore: [404], + index: INDEX_NAMES.BEATS, + type: '_doc', + }; + + const response = await this.framework.callWithInternalUser('get', params); + if (!response.found) { + return null; + } + + return get(response, '_source.beat'); + } + + public async insert(beat: CMBeat) { + const body = { + beat, + type: 'beat', + }; + + const params = { + body, + id: `beat:${beat.id}`, + index: INDEX_NAMES.BEATS, + refresh: 'wait_for', + type: '_doc', + }; + await this.framework.callWithInternalUser('create', params); + } + + public async update(beat: CMBeat) { + const body = { + beat, + type: 'beat', + }; + + const params = { + body, + id: `beat:${beat.id}`, + index: INDEX_NAMES.BEATS, + refresh: 'wait_for', + type: '_doc', + }; + return await this.framework.callWithInternalUser('index', params); + } + + public async getWithIds(req: FrameworkRequest, beatIds: string[]) { + const ids = beatIds.map(beatId => `beat:${beatId}`); + + const params = { + _source: false, + body: { + ids, + }, + index: INDEX_NAMES.BEATS, + type: '_doc', + }; + const response = await this.framework.callWithRequest(req, 'mget', params); + return get(response, 'docs', []); + } + + // TODO merge with getBeatsWithIds + public async getVerifiedWithIds(req: FrameworkRequest, beatIds: string[]) { + const ids = beatIds.map(beatId => `beat:${beatId}`); + + const params = { + _sourceInclude: ['beat.id', 'beat.verified_on'], + body: { + ids, + }, + index: INDEX_NAMES.BEATS, + type: '_doc', + }; + const response = await this.framework.callWithRequest(req, 'mget', params); + return get(response, 'docs', []); + } + + public async verifyBeats(req: FrameworkRequest, beatIds: string[]) { + if (!Array.isArray(beatIds) || beatIds.length === 0) { + return []; + } + + const verifiedOn = moment().toJSON(); + const body = flatten( + beatIds.map(beatId => [ + { update: { _id: `beat:${beatId}` } }, + { doc: { beat: { verified_on: verifiedOn } } }, + ]) + ); + + const params = { + body, + index: INDEX_NAMES.BEATS, + refresh: 'wait_for', + type: '_doc', + }; + + const response = await this.framework.callWithRequest(req, 'bulk', params); + return get(response, 'items', []); + } + + public async getAll(req: FrameworkRequest) { + const params = { + index: INDEX_NAMES.BEATS, + q: 'type:beat', + type: '_doc', + }; + const response = await this.framework.callWithRequest( + req, + 'search', + params + ); + + const beats = get(response, 'hits.hits', []); + return beats.map((beat: any) => omit(beat._source.beat, ['access_token'])); + } + + public async removeTagsFromBeats( + req: FrameworkRequest, + removals: CMTagAssignment[] + ): Promise { + const body = flatten( + removals.map(({ beatId, tag }) => { + const script = + '' + + 'def beat = ctx._source.beat; ' + + 'if (beat.tags != null) { ' + + ' beat.tags.removeAll([params.tag]); ' + + '}'; + + return [ + { update: { _id: `beat:${beatId}` } }, + { script: { source: script, params: { tag } } }, + ]; + }) + ); + + const params = { + body, + index: INDEX_NAMES.BEATS, + refresh: 'wait_for', + type: '_doc', + }; + + const response = await this.framework.callWithRequest(req, 'bulk', params); + return get(response, 'items', []).map( + (item: any, resultIdx: number) => ({ + idxInRequest: removals[resultIdx].idxInRequest, + result: item.update.result, + status: item.update.status, + }) + ); + } + + public async assignTagsToBeats( + req: FrameworkRequest, + assignments: CMTagAssignment[] + ): Promise { + const body = flatten( + assignments.map(({ beatId, tag }) => { + const script = + '' + + 'def beat = ctx._source.beat; ' + + 'if (beat.tags == null) { ' + + ' beat.tags = []; ' + + '} ' + + 'if (!beat.tags.contains(params.tag)) { ' + + ' beat.tags.add(params.tag); ' + + '}'; + + return [ + { update: { _id: `beat:${beatId}` } }, + { script: { source: script, params: { tag } } }, + ]; + }) + ); + + const params = { + body, + index: INDEX_NAMES.BEATS, + refresh: 'wait_for', + type: '_doc', + }; + + const response = await this.framework.callWithRequest(req, 'bulk', params); + return get(response, 'items', []).map((item: any, resultIdx: any) => ({ + idxInRequest: assignments[resultIdx].idxInRequest, + result: item.update.result, + status: item.update.status, + })); + } +} diff --git a/x-pack/plugins/beats/server/lib/adapters/famework/kibana/kibana_framework_adapter.ts b/x-pack/plugins/beats/server/lib/adapters/famework/kibana/kibana_framework_adapter.ts new file mode 100644 index 0000000000000..6fc2fc4853b03 --- /dev/null +++ b/x-pack/plugins/beats/server/lib/adapters/famework/kibana/kibana_framework_adapter.ts @@ -0,0 +1,82 @@ +/* + * 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 { + BackendFrameworkAdapter, + FrameworkRequest, + FrameworkRouteOptions, + WrappableRequest, +} from '../../../lib'; + +import { IStrictReply, Request, Server } from 'hapi'; +import { + internalFrameworkRequest, + wrapRequest, +} from '../../../../utils/wrap_request'; + +export class KibanaBackendFrameworkAdapter implements BackendFrameworkAdapter { + public version: string; + + private server: Server; + + constructor(hapiServer: Server) { + this.server = hapiServer; + this.version = hapiServer.plugins.kibana.status.plugin.version; + } + + public getSetting(settingPath: string) { + // TODO type check this properly + // @ts-ignore + return this.server.config().get(settingPath); + } + + public exposeStaticDir(urlPath: string, dir: string): void { + this.server.route({ + handler: { + directory: { + path: dir, + }, + }, + method: 'GET', + path: urlPath, + }); + } + + public registerRoute( + route: FrameworkRouteOptions + ) { + const wrappedHandler = (request: any, reply: IStrictReply) => + route.handler(wrapRequest(request), reply); + + this.server.route({ + config: route.config, + handler: wrappedHandler, + method: route.method, + path: route.path, + }); + } + + public installIndexTemplate(name: string, template: {}) { + return this.callWithInternalUser('indices.putTemplate', { + body: template, + name, + }); + } + + public async callWithInternalUser(esMethod: string, options: {}) { + const { elasticsearch } = this.server.plugins; + const { callWithInternalUser } = elasticsearch.getCluster('admin'); + return await callWithInternalUser(esMethod, options); + } + + public async callWithRequest(req: FrameworkRequest, ...rest: any[]) { + const internalRequest = req[internalFrameworkRequest]; + const { elasticsearch } = internalRequest.server.plugins; + const { callWithRequest } = elasticsearch.getCluster('data'); + const fields = await callWithRequest(internalRequest, ...rest); + return fields; + } +} diff --git a/x-pack/plugins/beats/server/lib/adapters/tags/elasticsearch_tags_adapter.ts b/x-pack/plugins/beats/server/lib/adapters/tags/elasticsearch_tags_adapter.ts new file mode 100644 index 0000000000000..2293ba77677fd --- /dev/null +++ b/x-pack/plugins/beats/server/lib/adapters/tags/elasticsearch_tags_adapter.ts @@ -0,0 +1,57 @@ +/* + * 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 { get } from 'lodash'; +import { INDEX_NAMES } from '../../../../common/constants'; +import { + BackendFrameworkAdapter, + BeatTag, + CMTagsAdapter, + FrameworkRequest, +} from '../../lib'; + +export class ElasticsearchTagsAdapter implements CMTagsAdapter { + private framework: BackendFrameworkAdapter; + + constructor(framework: BackendFrameworkAdapter) { + this.framework = framework; + } + + public async getTagsWithIds(req: FrameworkRequest, tagIds: string[]) { + const ids = tagIds.map(tag => `tag:${tag}`); + + // TODO abstract to kibana adapter as the more generic getDocs + const params = { + _source: false, + body: { + ids, + }, + index: INDEX_NAMES.BEATS, + type: '_doc', + }; + const response = await this.framework.callWithRequest(req, 'mget', params); + return get(response, 'docs', []); + } + + public async upsertTag(req: FrameworkRequest, tag: BeatTag) { + const body = { + tag, + type: 'tag', + }; + + const params = { + body, + id: `tag:${tag.id}`, + index: INDEX_NAMES.BEATS, + refresh: 'wait_for', + type: '_doc', + }; + const response = await this.framework.callWithRequest(req, 'index', params); + + // TODO this is not something that works for TS... change this return type + return get(response, 'result'); + } +} diff --git a/x-pack/plugins/beats/server/lib/adapters/tokens/elasticsearch_tokens_adapter.ts b/x-pack/plugins/beats/server/lib/adapters/tokens/elasticsearch_tokens_adapter.ts new file mode 100644 index 0000000000000..c8969c7ab08d0 --- /dev/null +++ b/x-pack/plugins/beats/server/lib/adapters/tokens/elasticsearch_tokens_adapter.ts @@ -0,0 +1,83 @@ +/* + * 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 { flatten, get } from 'lodash'; +import { INDEX_NAMES } from '../../../../common/constants'; +import { + BackendFrameworkAdapter, + CMTokensAdapter, + EnrollmentToken, + FrameworkRequest, +} from '../../lib'; + +export class ElasticsearchTokensAdapter implements CMTokensAdapter { + private framework: BackendFrameworkAdapter; + + constructor(framework: BackendFrameworkAdapter) { + this.framework = framework; + } + + public async deleteEnrollmentToken(enrollmentToken: string) { + const params = { + id: `enrollment_token:${enrollmentToken}`, + index: INDEX_NAMES.BEATS, + type: '_doc', + }; + + return this.framework.callWithInternalUser('delete', params); + } + + public async getEnrollmentToken( + tokenString: string + ): Promise { + const params = { + id: `enrollment_token:${tokenString}`, + ignore: [404], + index: INDEX_NAMES.BEATS, + type: '_doc', + }; + + const response = await this.framework.callWithInternalUser('get', params); + const tokenDetails = get( + response, + '_source.enrollment_token', + { + expires_on: '0', + token: null, + } + ); + + // Elasticsearch might return fast if the token is not found. OR it might return fast + // if the token *is* found. Either way, an attacker could using a timing attack to figure + // out whether a token is valid or not. So we introduce a random delay in returning from + // this function to obscure the actual time it took for Elasticsearch to find the token. + const randomDelayInMs = 25 + Math.round(Math.random() * 200); // between 25 and 225 ms + return new Promise(resolve => + setTimeout(() => resolve(tokenDetails), randomDelayInMs) + ); + } + + public async upsertTokens(req: FrameworkRequest, tokens: EnrollmentToken[]) { + const body = flatten( + tokens.map(token => [ + { index: { _id: `enrollment_token:${token.token}` } }, + { + enrollment_token: token, + type: 'enrollment_token', + }, + ]) + ); + + const params = { + body, + index: INDEX_NAMES.BEATS, + refresh: 'wait_for', + type: '_doc', + }; + + await this.framework.callWithRequest(req, 'bulk', params); + } +} diff --git a/x-pack/plugins/beats/server/lib/client/call_with_internal_user_factory.js b/x-pack/plugins/beats/server/lib/client/call_with_internal_user_factory.js deleted file mode 100644 index 8b5dbed773430..0000000000000 --- a/x-pack/plugins/beats/server/lib/client/call_with_internal_user_factory.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 { once } from 'lodash'; - -const callWithInternalUser = once((server) => { - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - return callWithInternalUser; -}); - -export const callWithInternalUserFactory = (server) => { - return callWithInternalUser(server); -}; diff --git a/x-pack/plugins/beats/server/lib/client/call_with_request_factory.js b/x-pack/plugins/beats/server/lib/client/call_with_request_factory.js deleted file mode 100644 index c81670ed0cdec..0000000000000 --- a/x-pack/plugins/beats/server/lib/client/call_with_request_factory.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 { once } from 'lodash'; - -const callWithRequest = once((server) => { - const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); - return callWithRequest; -}); - -export const callWithRequestFactory = (server, request) => { - return (...args) => { - return callWithRequest(server)(request, ...args); - }; -}; diff --git a/x-pack/plugins/beats/server/lib/client/index.js b/x-pack/plugins/beats/server/lib/client/index.js deleted file mode 100644 index cdeee091cc66f..0000000000000 --- a/x-pack/plugins/beats/server/lib/client/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/* - * 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. - */ - -export { callWithRequestFactory } from './call_with_request_factory'; -export { callWithInternalUserFactory } from './call_with_internal_user_factory'; diff --git a/x-pack/plugins/beats/server/lib/compose/kibana.ts b/x-pack/plugins/beats/server/lib/compose/kibana.ts new file mode 100644 index 0000000000000..ff478646aea89 --- /dev/null +++ b/x-pack/plugins/beats/server/lib/compose/kibana.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElasticsearchBeatsAdapter } from '../adapters/beats/elasticsearch_beats_adapter'; +import { ElasticsearchTagsAdapter } from '../adapters/tags/elasticsearch_tags_adapter'; +import { ElasticsearchTokensAdapter } from '../adapters/tokens/elasticsearch_tokens_adapter'; + +import { KibanaBackendFrameworkAdapter } from '../adapters/famework/kibana/kibana_framework_adapter'; + +import { CMBeatsDomain } from '../domains/beats'; +import { CMTagsDomain } from '../domains/tags'; +import { CMTokensDomain } from '../domains/tokens'; + +import { CMDomainLibs, CMServerLibs } from '../lib'; + +import { Server } from 'hapi'; + +export function compose(server: Server): CMServerLibs { + const framework = new KibanaBackendFrameworkAdapter(server); + + const tags = new CMTagsDomain(new ElasticsearchTagsAdapter(framework)); + const tokens = new CMTokensDomain(new ElasticsearchTokensAdapter(framework), { + framework, + }); + const beats = new CMBeatsDomain(new ElasticsearchBeatsAdapter(framework), { + tags, + tokens, + }); + + const domainLibs: CMDomainLibs = { + beats, + tags, + tokens, + }; + + const libs: CMServerLibs = { + framework, + ...domainLibs, + }; + + return libs; +} diff --git a/x-pack/plugins/beats/server/lib/crypto/are_tokens_equal.js b/x-pack/plugins/beats/server/lib/crypto/are_tokens_equal.js deleted file mode 100644 index a6ed171d30e5e..0000000000000 --- a/x-pack/plugins/beats/server/lib/crypto/are_tokens_equal.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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 { timingSafeEqual } from 'crypto'; - -const RANDOM_TOKEN_1 = 'b48c4bda384a40cb91c6eb9b8849e77f'; -const RANDOM_TOKEN_2 = '80a3819e3cd64f4399f1d4886be7a08b'; - -export function areTokensEqual(token1, token2) { - if ((typeof token1 !== 'string') || (typeof token2 !== 'string') || (token1.length !== token2.length)) { - // This prevents a more subtle timing attack where we know already the tokens aren't going to - // match but still we don't return fast. Instead we compare two pre-generated random tokens using - // the same comparison algorithm that we would use to compare two equal-length tokens. - return timingSafeEqual(Buffer.from(RANDOM_TOKEN_1, 'utf8'), Buffer.from(RANDOM_TOKEN_2, 'utf8')); - } - - return timingSafeEqual(Buffer.from(token1, 'utf8'), Buffer.from(token2, 'utf8')); -} diff --git a/x-pack/plugins/beats/server/lib/domains/beats.ts b/x-pack/plugins/beats/server/lib/domains/beats.ts new file mode 100644 index 0000000000000..c0d9ec704e2b1 --- /dev/null +++ b/x-pack/plugins/beats/server/lib/domains/beats.ts @@ -0,0 +1,259 @@ +/* + * 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. + */ + +/* + * 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 { uniq } from 'lodash'; +import uuid from 'uuid'; +import { findNonExistentItems } from '../../utils/find_non_existent_items'; + +import { + CMAssignmentReturn, + CMBeat, + CMBeatsAdapter, + CMDomainLibs, + CMRemovalReturn, + CMTagAssignment, + FrameworkRequest, +} from '../lib'; + +export class CMBeatsDomain { + private adapter: CMBeatsAdapter; + private tags: CMDomainLibs['tags']; + private tokens: CMDomainLibs['tokens']; + + constructor( + adapter: CMBeatsAdapter, + libs: { tags: CMDomainLibs['tags']; tokens: CMDomainLibs['tokens'] } + ) { + this.adapter = adapter; + this.tags = libs.tags; + this.tokens = libs.tokens; + } + + public async update( + beatId: string, + accessToken: string, + beatData: Partial + ) { + const beat = await this.adapter.get(beatId); + + // TODO make return type enum + if (beat === null) { + return 'beat-not-found'; + } + + const isAccessTokenValid = this.tokens.areTokensEqual( + beat.access_token, + accessToken + ); + if (!isAccessTokenValid) { + return 'invalid-access-token'; + } + const isBeatVerified = beat.hasOwnProperty('verified_on'); + if (!isBeatVerified) { + return 'beat-not-verified'; + } + + await this.adapter.update({ + ...beat, + ...beatData, + }); + } + + // TODO more strongly type this + public async enrollBeat( + beatId: string, + remoteAddress: string, + beat: Partial + ) { + // TODO move this to the token lib + const accessToken = uuid.v4().replace(/-/g, ''); + await this.adapter.insert({ + ...beat, + access_token: accessToken, + host_ip: remoteAddress, + id: beatId, + } as CMBeat); + return { accessToken }; + } + + public async removeTagsFromBeats( + req: FrameworkRequest, + removals: CMTagAssignment[] + ): Promise { + const beatIds = uniq(removals.map(removal => removal.beatId)); + const tagIds = uniq(removals.map(removal => removal.tag)); + + const response = { + removals: removals.map(() => ({ status: null })), + }; + + const beats = await this.adapter.getWithIds(req, beatIds); + const tags = await this.tags.getTagsWithIds(req, tagIds); + + // Handle assignments containing non-existing beat IDs or tags + const nonExistentBeatIds = findNonExistentItems(beats, beatIds); + const nonExistentTags = await findNonExistentItems(tags, tagIds); + + addNonExistentItemToResponse( + response, + removals, + nonExistentBeatIds, + nonExistentTags, + 'removals' + ); + + // TODO abstract this + const validRemovals = removals + .map((removal, idxInRequest) => ({ + beatId: removal.beatId, + idxInRequest, // so we can add the result of this removal to the correct place in the response + tag: removal.tag, + })) + .filter((removal, idx) => response.removals[idx].status === null); + + if (validRemovals.length > 0) { + const removalResults = await this.adapter.removeTagsFromBeats( + req, + validRemovals + ); + return addToResultsToResponse('removals', response, removalResults); + } + return response; + } + + public async getAllBeats(req: FrameworkRequest) { + return await this.adapter.getAll(req); + } + + // TODO cleanup return value, should return a status enum + public async verifyBeats(req: FrameworkRequest, beatIds: string[]) { + const beatsFromEs = await this.adapter.getVerifiedWithIds(req, beatIds); + + const nonExistentBeatIds = beatsFromEs.reduce( + (nonExistentIds: any, beatFromEs: any, idx: any) => { + if (!beatFromEs.found) { + nonExistentIds.push(beatIds[idx]); + } + return nonExistentIds; + }, + [] + ); + + const alreadyVerifiedBeatIds = beatsFromEs + .filter((beat: any) => beat.found) + .filter((beat: any) => beat._source.beat.hasOwnProperty('verified_on')) + .map((beat: any) => beat._source.beat.id); + + const toBeVerifiedBeatIds = beatsFromEs + .filter((beat: any) => beat.found) + .filter((beat: any) => !beat._source.beat.hasOwnProperty('verified_on')) + .map((beat: any) => beat._source.beat.id); + + const verifications = await this.adapter.verifyBeats( + req, + toBeVerifiedBeatIds + ); + return { + alreadyVerifiedBeatIds, + nonExistentBeatIds, + toBeVerifiedBeatIds, + verifications, + }; + } + + public async assignTagsToBeats( + req: FrameworkRequest, + assignments: CMTagAssignment[] + ): Promise { + const beatIds = uniq(assignments.map(assignment => assignment.beatId)); + const tagIds = uniq(assignments.map(assignment => assignment.tag)); + + const response = { + assignments: assignments.map(() => ({ status: null })), + }; + const beats = await this.adapter.getWithIds(req, beatIds); + const tags = await this.tags.getTagsWithIds(req, tagIds); + + // Handle assignments containing non-existing beat IDs or tags + const nonExistentBeatIds = findNonExistentItems(beats, beatIds); + const nonExistentTags = findNonExistentItems(tags, tagIds); + + // TODO break out back into route / function response + // TODO causes function to error if a beat or tag does not exist + addNonExistentItemToResponse( + response, + assignments, + nonExistentBeatIds, + nonExistentTags, + 'assignments' + ); + + // TODO abstract this + const validAssignments = assignments + .map((assignment, idxInRequest) => ({ + beatId: assignment.beatId, + idxInRequest, // so we can add the result of this assignment to the correct place in the response + tag: assignment.tag, + })) + .filter((assignment, idx) => response.assignments[idx].status === null); + + if (validAssignments.length > 0) { + const assignmentResults = await this.adapter.assignTagsToBeats( + req, + validAssignments + ); + + // TODO This should prob not mutate + return addToResultsToResponse('assignments', response, assignmentResults); + } + return response; + } +} + +// TODO abstract to the route, also the key arg is a temp fix +function addNonExistentItemToResponse( + response: any, + assignments: any, + nonExistentBeatIds: any, + nonExistentTags: any, + key: string +) { + assignments.forEach(({ beatId, tag }: CMTagAssignment, idx: any) => { + const isBeatNonExistent = nonExistentBeatIds.includes(beatId); + const isTagNonExistent = nonExistentTags.includes(tag); + + if (isBeatNonExistent && isTagNonExistent) { + response[key][idx].status = 404; + response[key][idx].result = `Beat ${beatId} and tag ${tag} not found`; + } else if (isBeatNonExistent) { + response[key][idx].status = 404; + response[key][idx].result = `Beat ${beatId} not found`; + } else if (isTagNonExistent) { + response[key][idx].status = 404; + response[key][idx].result = `Tag ${tag} not found`; + } + }); +} + +// TODO dont mutate response +function addToResultsToResponse( + key: string, + response: any, + assignmentResults: any +) { + assignmentResults.forEach((assignmentResult: any) => { + const { idxInRequest, status, result } = assignmentResult; + response[key][idxInRequest].status = status; + response[key][idxInRequest].result = result; + }); + return response; +} diff --git a/x-pack/plugins/beats/server/lib/domains/tags.ts b/x-pack/plugins/beats/server/lib/domains/tags.ts new file mode 100644 index 0000000000000..43bb8dfed15a1 --- /dev/null +++ b/x-pack/plugins/beats/server/lib/domains/tags.ts @@ -0,0 +1,90 @@ +/* + * 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 { intersection, uniq, values } from 'lodash'; +import { UNIQUENESS_ENFORCING_TYPES } from '../../../common/constants'; +import { CMTagsAdapter, ConfigurationBlock, FrameworkRequest } from '../lib'; +import { entries } from './../../utils/polyfills'; + +export class CMTagsDomain { + private adapter: CMTagsAdapter; + constructor(adapter: CMTagsAdapter) { + this.adapter = adapter; + } + + public async getTagsWithIds(req: FrameworkRequest, tagIds: string[]) { + return await this.adapter.getTagsWithIds(req, tagIds); + } + + public async saveTag( + req: FrameworkRequest, + tagId: string, + configs: ConfigurationBlock[] + ) { + const { isValid, message } = await this.validateConfigurationBlocks( + configs + ); + if (!isValid) { + return { isValid, result: message }; + } + + const tag = { + configuration_blocks: configs, + id: tagId, + }; + return { + isValid: true, + result: await this.adapter.upsertTag(req, tag), + }; + } + + private validateConfigurationBlocks(configurationBlocks: any) { + const types = uniq(configurationBlocks.map((block: any) => block.type)); + + // If none of the types in the given configuration blocks are uniqueness-enforcing, + // we don't need to perform any further validation checks. + const uniquenessEnforcingTypes = intersection( + types, + UNIQUENESS_ENFORCING_TYPES + ); + if (uniquenessEnforcingTypes.length === 0) { + return { isValid: true }; + } + + // Count the number of uniqueness-enforcing types in the given configuration blocks + const typeCountMap = configurationBlocks.reduce((map: any, block: any) => { + const { type } = block; + if (!uniquenessEnforcingTypes.includes(type)) { + return map; + } + + const count = map[type] || 0; + return { + ...map, + [type]: count + 1, + }; + }, {}); + + // If there is no more than one of any uniqueness-enforcing types in the given + // configuration blocks, we don't need to perform any further validation checks. + if (values(typeCountMap).filter(count => count > 1).length === 0) { + return { isValid: true }; + } + + const message = entries(typeCountMap) + .filter(([, count]) => count > 1) + .map( + ([type, count]) => + `Expected only one configuration block of type '${type}' but found ${count}` + ) + .join(' '); + + return { + isValid: false, + message, + }; + } +} diff --git a/x-pack/plugins/beats/server/lib/domains/tokens.ts b/x-pack/plugins/beats/server/lib/domains/tokens.ts new file mode 100644 index 0000000000000..6e55d78ecdcc8 --- /dev/null +++ b/x-pack/plugins/beats/server/lib/domains/tokens.ts @@ -0,0 +1,80 @@ +/* + * 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 { timingSafeEqual } from 'crypto'; +import moment from 'moment'; +import uuid from 'uuid'; +import { CMTokensAdapter, FrameworkRequest } from '../lib'; +import { BackendFrameworkAdapter } from '../lib'; + +const RANDOM_TOKEN_1 = 'b48c4bda384a40cb91c6eb9b8849e77f'; +const RANDOM_TOKEN_2 = '80a3819e3cd64f4399f1d4886be7a08b'; + +export class CMTokensDomain { + private adapter: CMTokensAdapter; + private framework: BackendFrameworkAdapter; + + constructor( + adapter: CMTokensAdapter, + libs: { framework: BackendFrameworkAdapter } + ) { + this.adapter = adapter; + this.framework = libs.framework; + } + + public async getEnrollmentToken(enrollmentToken: string) { + return await this.adapter.getEnrollmentToken(enrollmentToken); + } + + public async deleteEnrollmentToken(enrollmentToken: string) { + return await this.adapter.deleteEnrollmentToken(enrollmentToken); + } + + public areTokensEqual(token1: string, token2: string) { + if ( + typeof token1 !== 'string' || + typeof token2 !== 'string' || + token1.length !== token2.length + ) { + // This prevents a more subtle timing attack where we know already the tokens aren't going to + // match but still we don't return fast. Instead we compare two pre-generated random tokens using + // the same comparison algorithm that we would use to compare two equal-length tokens. + return timingSafeEqual( + Buffer.from(RANDOM_TOKEN_1, 'utf8'), + Buffer.from(RANDOM_TOKEN_2, 'utf8') + ); + } + + return timingSafeEqual( + Buffer.from(token1, 'utf8'), + Buffer.from(token2, 'utf8') + ); + } + + public async createEnrollmentTokens( + req: FrameworkRequest, + numTokens: number = 1 + ): Promise { + const tokens = []; + const enrollmentTokensTtlInSeconds = this.framework.getSetting( + 'xpack.beats.enrollmentTokensTtlInSeconds' + ); + const enrollmentTokenExpiration = moment() + .add(enrollmentTokensTtlInSeconds, 'seconds') + .toJSON(); + + while (tokens.length < numTokens) { + tokens.push({ + expires_on: enrollmentTokenExpiration, + token: uuid.v4().replace(/-/g, ''), + }); + } + + await this.adapter.upsertTokens(req, tokens); + + return tokens.map(token => token.token); + } +} diff --git a/x-pack/plugins/beats/server/lib/index_template/install_index_template.js b/x-pack/plugins/beats/server/lib/index_template/install_index_template.js deleted file mode 100644 index 01b080903ccac..0000000000000 --- a/x-pack/plugins/beats/server/lib/index_template/install_index_template.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 beatsIndexTemplate from './beats_template'; -import { callWithInternalUserFactory } from '../client'; - -const TEMPLATE_NAME = 'beats-template'; - -export function installIndexTemplate(server) { - const callWithInternalUser = callWithInternalUserFactory(server); - return callWithInternalUser('indices.putTemplate', { - name: TEMPLATE_NAME, - body: beatsIndexTemplate - }); -} diff --git a/x-pack/plugins/beats/server/lib/lib.ts b/x-pack/plugins/beats/server/lib/lib.ts new file mode 100644 index 0000000000000..37d0a989e4cf5 --- /dev/null +++ b/x-pack/plugins/beats/server/lib/lib.ts @@ -0,0 +1,212 @@ +/* + * 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 { IRouteAdditionalConfigurationOptions, IStrictReply } from 'hapi'; +import { internalFrameworkRequest } from '../utils/wrap_request'; +import { CMBeatsDomain } from './domains/beats'; +import { CMTagsDomain } from './domains/tags'; +import { CMTokensDomain } from './domains/tokens'; + +import { ConfigurationBlockTypes } from '../../common/constants'; + +export interface CMDomainLibs { + beats: CMBeatsDomain; + tags: CMTagsDomain; + tokens: CMTokensDomain; +} + +export interface CMServerLibs extends CMDomainLibs { + framework: BackendFrameworkAdapter; +} + +interface CMReturnedTagAssignment { + status: number | null; + result?: string; +} + +export interface CMAssignmentReturn { + assignments: CMReturnedTagAssignment[]; +} + +export interface CMRemovalReturn { + removals: CMReturnedTagAssignment[]; +} + +export interface ConfigurationBlock { + type: ConfigurationBlockTypes; + block_yml: string; +} + +export interface CMBeat { + id: string; + access_token: string; + verified_on: string; + type: string; + version: string; + host_ip: string; + host_name: string; + ephemeral_id: string; + local_configuration_yml: string; + tags: string; + central_configuration_yml: string; + metadata: {}; +} + +export interface BeatTag { + id: string; + configuration_blocks: ConfigurationBlock[]; +} + +export interface EnrollmentToken { + token: string | null; + expires_on: string; +} + +export interface CMTokensAdapter { + deleteEnrollmentToken(enrollmentToken: string): Promise; + getEnrollmentToken(enrollmentToken: string): Promise; + upsertTokens(req: FrameworkRequest, tokens: EnrollmentToken[]): Promise; +} + +// FIXME: fix getTagsWithIds return type +export interface CMTagsAdapter { + getTagsWithIds(req: FrameworkRequest, tagIds: string[]): any; + upsertTag(req: FrameworkRequest, tag: BeatTag): Promise<{}>; +} + +// FIXME: fix getBeatsWithIds return type +export interface CMBeatsAdapter { + insert(beat: CMBeat): Promise; + update(beat: CMBeat): Promise; + get(id: string): any; + getAll(req: FrameworkRequest): any; + getWithIds(req: FrameworkRequest, beatIds: string[]): any; + getVerifiedWithIds(req: FrameworkRequest, beatIds: string[]): any; + verifyBeats(req: FrameworkRequest, beatIds: string[]): any; + removeTagsFromBeats( + req: FrameworkRequest, + removals: CMTagAssignment[] + ): Promise; + assignTagsToBeats( + req: FrameworkRequest, + assignments: CMTagAssignment[] + ): Promise; +} + +export interface CMTagAssignment { + beatId: string; + tag: string; + idxInRequest?: number; +} + +/** + * The following are generic types, sharable between projects + */ + +export interface BackendFrameworkAdapter { + version: string; + getSetting(settingPath: string): string | number; + exposeStaticDir(urlPath: string, dir: string): void; + installIndexTemplate(name: string, template: {}): void; + registerRoute( + route: FrameworkRouteOptions + ): void; + callWithInternalUser(esMethod: string, options: {}): Promise; + callWithRequest( + req: FrameworkRequest, + method: 'search', + options?: object + ): Promise>; + callWithRequest( + req: FrameworkRequest, + method: 'fieldCaps', + options?: object + ): Promise; + callWithRequest( + req: FrameworkRequest, + method: string, + options?: object + ): Promise; +} + +interface DatabaseFieldCapsResponse extends DatabaseResponse { + fields: FieldsResponse; +} + +export interface FieldsResponse { + [name: string]: FieldDef; +} + +export interface FieldDetails { + searchable: boolean; + aggregatable: boolean; + type: string; +} + +export interface FieldDef { + [type: string]: FieldDetails; +} + +export interface FrameworkRequest< + InternalRequest extends WrappableRequest = WrappableRequest +> { + [internalFrameworkRequest]: InternalRequest; + headers: InternalRequest['headers']; + info: InternalRequest['info']; + payload: InternalRequest['payload']; + params: InternalRequest['params']; + query: InternalRequest['query']; +} + +export interface FrameworkRouteOptions< + RouteRequest extends WrappableRequest, + RouteResponse +> { + path: string; + method: string | string[]; + vhost?: string; + handler: FrameworkRouteHandler; + config?: Pick< + IRouteAdditionalConfigurationOptions, + Exclude + >; +} + +export type FrameworkRouteHandler< + RouteRequest extends WrappableRequest, + RouteResponse +> = ( + request: FrameworkRequest, + reply: IStrictReply +) => void; + +export interface WrappableRequest< + Payload = any, + Params = any, + Query = any, + Headers = any, + Info = any +> { + headers: Headers; + info: Info; + payload: Payload; + params: Params; + query: Query; +} + +interface DatabaseResponse { + took: number; + timeout: boolean; +} + +interface DatabaseSearchResponse + extends DatabaseResponse { + aggregations?: Aggregations; + hits: { + total: number; + hits: Hit[]; + }; +} diff --git a/x-pack/plugins/beats/server/management_server.ts b/x-pack/plugins/beats/server/management_server.ts new file mode 100644 index 0000000000000..ed0917eda8ced --- /dev/null +++ b/x-pack/plugins/beats/server/management_server.ts @@ -0,0 +1,30 @@ +/* + * 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 { CMServerLibs } from './lib/lib'; +import { createBeatEnrollmentRoute } from './rest_api/beats/enroll'; +import { createListAgentsRoute } from './rest_api/beats/list'; +import { createTagAssignmentsRoute } from './rest_api/beats/tag_assignment'; +import { createTagRemovalsRoute } from './rest_api/beats/tag_removal'; +import { createBeatUpdateRoute } from './rest_api/beats/update'; +import { createBeatVerificationRoute } from './rest_api/beats/verify'; +import { createSetTagRoute } from './rest_api/tags/set'; +import { createTokensRoute } from './rest_api/tokens/create'; + +import { beatsIndexTemplate } from './utils/index_templates'; + +export const initManagementServer = (libs: CMServerLibs) => { + libs.framework.installIndexTemplate('beats-template', beatsIndexTemplate); + + libs.framework.registerRoute(createTagAssignmentsRoute(libs)); + libs.framework.registerRoute(createListAgentsRoute(libs)); + libs.framework.registerRoute(createTagRemovalsRoute(libs)); + libs.framework.registerRoute(createBeatEnrollmentRoute(libs)); + libs.framework.registerRoute(createSetTagRoute(libs)); + libs.framework.registerRoute(createTokensRoute(libs)); + libs.framework.registerRoute(createBeatVerificationRoute(libs)); + libs.framework.registerRoute(createBeatUpdateRoute(libs)); +}; diff --git a/x-pack/plugins/beats/server/rest_api/beats/enroll.ts b/x-pack/plugins/beats/server/rest_api/beats/enroll.ts new file mode 100644 index 0000000000000..fe154592564ae --- /dev/null +++ b/x-pack/plugins/beats/server/rest_api/beats/enroll.ts @@ -0,0 +1,63 @@ +/* + * 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 Joi from 'joi'; +import { omit } from 'lodash'; +import moment from 'moment'; +import { CMServerLibs } from '../../lib/lib'; +import { wrapEsError } from '../../utils/error_wrappers'; + +// TODO: add license check pre-hook +// TODO: write to Kibana audit log file +export const createBeatEnrollmentRoute = (libs: CMServerLibs) => ({ + config: { + auth: false, + validate: { + headers: Joi.object({ + 'kbn-beats-enrollment-token': Joi.string().required(), + }).options({ + allowUnknown: true, + }), + payload: Joi.object({ + host_name: Joi.string().required(), + type: Joi.string().required(), + version: Joi.string().required(), + }).required(), + }, + }, + handler: async (request: any, reply: any) => { + const { beatId } = request.params; + const enrollmentToken = request.headers['kbn-beats-enrollment-token']; + + try { + const { + token, + expires_on: expiresOn, + } = await libs.tokens.getEnrollmentToken(enrollmentToken); + + if (!token) { + return reply({ message: 'Invalid enrollment token' }).code(400); + } + if (moment(expiresOn).isBefore(moment())) { + return reply({ message: 'Expired enrollment token' }).code(400); + } + const { accessToken } = await libs.beats.enrollBeat( + beatId, + request.info.remoteAddress, + omit(request.payload, 'enrollment_token') + ); + + await libs.tokens.deleteEnrollmentToken(enrollmentToken); + + reply({ access_token: accessToken }).code(201); + } catch (err) { + // TODO move this to kibana route thing in adapter + return reply(wrapEsError(err)); + } + }, + method: 'POST', + path: '/api/beats/agent/{beatId}', +}); diff --git a/x-pack/plugins/beats/server/rest_api/beats/list.ts b/x-pack/plugins/beats/server/rest_api/beats/list.ts new file mode 100644 index 0000000000000..8263d1c0ff63f --- /dev/null +++ b/x-pack/plugins/beats/server/rest_api/beats/list.ts @@ -0,0 +1,23 @@ +/* + * 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 { CMServerLibs } from '../../lib/lib'; +import { wrapEsError } from '../../utils/error_wrappers'; + +// TODO: add license check pre-hook +export const createListAgentsRoute = (libs: CMServerLibs) => ({ + handler: async (request: any, reply: any) => { + try { + const beats = await libs.beats.getAllBeats(request); + reply({ beats }); + } catch (err) { + // TODO move this to kibana route thing in adapter + return reply(wrapEsError(err)); + } + }, + method: 'GET', + path: '/api/beats/agents', +}); diff --git a/x-pack/plugins/beats/server/rest_api/beats/tag_assignment.ts b/x-pack/plugins/beats/server/rest_api/beats/tag_assignment.ts new file mode 100644 index 0000000000000..d06c016ce6d12 --- /dev/null +++ b/x-pack/plugins/beats/server/rest_api/beats/tag_assignment.ts @@ -0,0 +1,48 @@ +/* + * 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 Joi from 'joi'; +import { CMServerLibs } from '../../lib/lib'; +import { wrapEsError } from '../../utils/error_wrappers'; + +// TODO: add license check pre-hook +// TODO: write to Kibana audit log file +export const createTagAssignmentsRoute = (libs: CMServerLibs) => ({ + config: { + validate: { + payload: Joi.object({ + assignments: Joi.array().items( + Joi.object({ + beat_id: Joi.string().required(), + tag: Joi.string().required(), + }) + ), + }).required(), + }, + }, + handler: async (request: any, reply: any) => { + const { assignments } = request.payload; + + // TODO abstract or change API to keep beatId consistent + const tweakedAssignments = assignments.map((assignment: any) => ({ + beatId: assignment.beat_id, + tag: assignment.tag, + })); + + try { + const response = await libs.beats.assignTagsToBeats( + request, + tweakedAssignments + ); + reply(response); + } catch (err) { + // TODO move this to kibana route thing in adapter + return reply(wrapEsError(err)); + } + }, + method: 'POST', + path: '/api/beats/agents_tags/assignments', +}); diff --git a/x-pack/plugins/beats/server/rest_api/beats/tag_removal.ts b/x-pack/plugins/beats/server/rest_api/beats/tag_removal.ts new file mode 100644 index 0000000000000..4da33dbd50cfc --- /dev/null +++ b/x-pack/plugins/beats/server/rest_api/beats/tag_removal.ts @@ -0,0 +1,48 @@ +/* + * 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 Joi from 'joi'; +import { CMServerLibs } from '../../lib/lib'; +import { wrapEsError } from '../../utils/error_wrappers'; + +// TODO: add license check pre-hook +// TODO: write to Kibana audit log file +export const createTagRemovalsRoute = (libs: CMServerLibs) => ({ + config: { + validate: { + payload: Joi.object({ + removals: Joi.array().items( + Joi.object({ + beat_id: Joi.string().required(), + tag: Joi.string().required(), + }) + ), + }).required(), + }, + }, + handler: async (request: any, reply: any) => { + const { removals } = request.payload; + + // TODO abstract or change API to keep beatId consistent + const tweakedRemovals = removals.map((removal: any) => ({ + beatId: removal.beat_id, + tag: removal.tag, + })); + + try { + const response = await libs.beats.removeTagsFromBeats( + request, + tweakedRemovals + ); + reply(response); + } catch (err) { + // TODO move this to kibana route thing in adapter + return reply(wrapEsError(err)); + } + }, + method: 'POST', + path: '/api/beats/agents_tags/removals', +}); diff --git a/x-pack/plugins/beats/server/rest_api/beats/update.ts b/x-pack/plugins/beats/server/rest_api/beats/update.ts new file mode 100644 index 0000000000000..41d403399d45f --- /dev/null +++ b/x-pack/plugins/beats/server/rest_api/beats/update.ts @@ -0,0 +1,62 @@ +/* + * 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 Joi from 'joi'; +import { CMServerLibs } from '../../lib/lib'; +import { wrapEsError } from '../../utils/error_wrappers'; + +// TODO: add license check pre-hook +// TODO: write to Kibana audit log file (include who did the verification as well) +export const createBeatUpdateRoute = (libs: CMServerLibs) => ({ + config: { + auth: false, + validate: { + headers: Joi.object({ + 'kbn-beats-access-token': Joi.string().required(), + }).options({ + allowUnknown: true, + }), + params: Joi.object({ + beatId: Joi.string(), + }), + payload: Joi.object({ + ephemeral_id: Joi.string(), + host_name: Joi.string(), + local_configuration_yml: Joi.string(), + metadata: Joi.object(), + type: Joi.string(), + version: Joi.string(), + }).required(), + }, + }, + handler: async (request: any, reply: any) => { + const { beatId } = request.params; + const accessToken = request.headers['kbn-beats-access-token']; + const remoteAddress = request.info.remoteAddress; + + try { + const status = await libs.beats.update(beatId, accessToken, { + ...request.payload, + host_ip: remoteAddress, + }); + + switch (status) { + case 'beat-not-found': + return reply({ message: 'Beat not found' }).code(404); + case 'invalid-access-token': + return reply({ message: 'Invalid access token' }).code(401); + case 'beat-not-verified': + return reply({ message: 'Beat has not been verified' }).code(400); + } + + reply().code(204); + } catch (err) { + return reply(wrapEsError(err)); + } + }, + method: 'PUT', + path: '/api/beats/agent/{beatId}', +}); diff --git a/x-pack/plugins/beats/server/rest_api/beats/verify.ts b/x-pack/plugins/beats/server/rest_api/beats/verify.ts new file mode 100644 index 0000000000000..866fa77d0c337 --- /dev/null +++ b/x-pack/plugins/beats/server/rest_api/beats/verify.ts @@ -0,0 +1,73 @@ +/* + * 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 Joi from 'joi'; +import { CMServerLibs } from '../../lib/lib'; +import { wrapEsError } from '../../utils/error_wrappers'; + +// TODO: add license check pre-hook +// TODO: write to Kibana audit log file +export const createBeatVerificationRoute = (libs: CMServerLibs) => ({ + config: { + auth: false, + validate: { + payload: Joi.object({ + beats: Joi.array() + .items({ + id: Joi.string().required(), + }) + .min(1), + }).required(), + }, + }, + handler: async (request: any, reply: any) => { + const beats = [...request.payload.beats]; + const beatIds = beats.map(beat => beat.id); + + try { + const { + verifications, + alreadyVerifiedBeatIds, + toBeVerifiedBeatIds, + nonExistentBeatIds, + } = await libs.beats.verifyBeats(request, beatIds); + + const verifiedBeatIds = verifications.reduce( + (verifiedBeatList: any, verification: any, idx: any) => { + if (verification.update.status === 200) { + verifiedBeatList.push(toBeVerifiedBeatIds[idx]); + } + return verifiedBeatList; + }, + [] + ); + + // TODO calculation of status should be done in-lib, w/switch statement here + beats.forEach(beat => { + if (nonExistentBeatIds.includes(beat.id)) { + beat.status = 404; + beat.result = 'not found'; + } else if (alreadyVerifiedBeatIds.includes(beat.id)) { + beat.status = 200; + beat.result = 'already verified'; + } else if (verifiedBeatIds.includes(beat.id)) { + beat.status = 200; + beat.result = 'verified'; + } else { + beat.status = 400; + beat.result = 'not verified'; + } + }); + + const response = { beats }; + reply(response); + } catch (err) { + return reply(wrapEsError(err)); + } + }, + method: 'POST', + path: '/api/beats/agents/verify', +}); diff --git a/x-pack/plugins/beats/server/rest_api/tags/set.ts b/x-pack/plugins/beats/server/rest_api/tags/set.ts new file mode 100644 index 0000000000000..3f7e579bd91ae --- /dev/null +++ b/x-pack/plugins/beats/server/rest_api/tags/set.ts @@ -0,0 +1,57 @@ +/* + * 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 Joi from 'joi'; +import { get, values } from 'lodash'; +import { ConfigurationBlockTypes } from '../../../common/constants'; +import { CMServerLibs } from '../../lib/lib'; +import { wrapEsError } from '../../utils/error_wrappers'; + +// TODO: add license check pre-hook +// TODO: write to Kibana audit log file +export const createSetTagRoute = (libs: CMServerLibs) => ({ + config: { + validate: { + params: Joi.object({ + tag: Joi.string(), + }), + payload: Joi.object({ + configuration_blocks: Joi.array().items( + Joi.object({ + block_yml: Joi.string().required(), + type: Joi.string() + .only(values(ConfigurationBlockTypes)) + .required(), + }) + ), + }).allow(null), + }, + }, + handler: async (request: any, reply: any) => { + const configurationBlocks = get( + request, + 'payload.configuration_blocks', + [] + ); + try { + const { isValid, result } = await libs.tags.saveTag( + request, + request.params.tag, + configurationBlocks + ); + if (!isValid) { + return reply({ result }).code(400); + } + + reply().code(result === 'created' ? 201 : 200); + } catch (err) { + // TODO move this to kibana route thing in adapter + return reply(wrapEsError(err)); + } + }, + method: 'PUT', + path: '/api/beats/tag/{tag}', +}); diff --git a/x-pack/plugins/beats/server/rest_api/tokens/create.ts b/x-pack/plugins/beats/server/rest_api/tokens/create.ts new file mode 100644 index 0000000000000..b4f3e2c1a6246 --- /dev/null +++ b/x-pack/plugins/beats/server/rest_api/tokens/create.ts @@ -0,0 +1,42 @@ +/* + * 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 Joi from 'joi'; +import { get } from 'lodash'; +import { CMServerLibs } from '../../lib/lib'; +import { wrapEsError } from '../../utils/error_wrappers'; + +// TODO: add license check pre-hook +// TODO: write to Kibana audit log file +const DEFAULT_NUM_TOKENS = 1; +export const createTokensRoute = (libs: CMServerLibs) => ({ + config: { + validate: { + payload: Joi.object({ + num_tokens: Joi.number() + .optional() + .default(DEFAULT_NUM_TOKENS) + .min(1), + }).allow(null), + }, + }, + handler: async (request: any, reply: any) => { + const numTokens = get(request, 'payload.num_tokens', DEFAULT_NUM_TOKENS); + + try { + const tokens = await libs.tokens.createEnrollmentTokens( + request, + numTokens + ); + reply({ tokens }); + } catch (err) { + // TODO move this to kibana route thing in adapter + return reply(wrapEsError(err)); + } + }, + method: 'POST', + path: '/api/beats/enrollment_tokens', +}); diff --git a/x-pack/plugins/beats/server/routes/api/index.js b/x-pack/plugins/beats/server/routes/api/index.js deleted file mode 100644 index 6ec0ad737352a..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/index.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 { registerCreateEnrollmentTokensRoute } from './register_create_enrollment_tokens_route'; -import { registerEnrollBeatRoute } from './register_enroll_beat_route'; -import { registerListBeatsRoute } from './register_list_beats_route'; -import { registerVerifyBeatsRoute } from './register_verify_beats_route'; -import { registerUpdateBeatRoute } from './register_update_beat_route'; -import { registerSetTagRoute } from './register_set_tag_route'; -import { registerAssignTagsToBeatsRoute } from './register_assign_tags_to_beats_route'; -import { registerRemoveTagsFromBeatsRoute } from './register_remove_tags_from_beats_route'; - -export function registerApiRoutes(server) { - registerCreateEnrollmentTokensRoute(server); - registerEnrollBeatRoute(server); - registerListBeatsRoute(server); - registerVerifyBeatsRoute(server); - registerUpdateBeatRoute(server); - registerSetTagRoute(server); - registerAssignTagsToBeatsRoute(server); - registerRemoveTagsFromBeatsRoute(server); -} diff --git a/x-pack/plugins/beats/server/routes/api/register_assign_tags_to_beats_route.js b/x-pack/plugins/beats/server/routes/api/register_assign_tags_to_beats_route.js deleted file mode 100644 index 5f6c5f7a7b906..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/register_assign_tags_to_beats_route.js +++ /dev/null @@ -1,169 +0,0 @@ -/* - * 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 Joi from 'joi'; -import { - get, - flatten, - uniq -} from 'lodash'; -import { INDEX_NAMES } from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/client'; -import { wrapEsError } from '../../lib/error_wrappers'; - -async function getDocs(callWithRequest, ids) { - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - body: { ids }, - _source: false - }; - - const response = await callWithRequest('mget', params); - return get(response, 'docs', []); -} - -function getBeats(callWithRequest, beatIds) { - const ids = beatIds.map(beatId => `beat:${beatId}`); - return getDocs(callWithRequest, ids); -} - -function getTags(callWithRequest, tags) { - const ids = tags.map(tag => `tag:${tag}`); - return getDocs(callWithRequest, ids); -} - -async function findNonExistentItems(callWithRequest, items, getFn) { - const itemsFromEs = await getFn.call(null, callWithRequest, items); - return itemsFromEs.reduce((nonExistentItems, itemFromEs, idx) => { - if (!itemFromEs.found) { - nonExistentItems.push(items[idx]); - } - return nonExistentItems; - }, []); -} - -function findNonExistentBeatIds(callWithRequest, beatIds) { - return findNonExistentItems(callWithRequest, beatIds, getBeats); -} - -function findNonExistentTags(callWithRequest, tags) { - return findNonExistentItems(callWithRequest, tags, getTags); -} - -async function persistAssignments(callWithRequest, assignments) { - const body = flatten(assignments.map(({ beatId, tag }) => { - const script = '' - + 'def beat = ctx._source.beat; ' - + 'if (beat.tags == null) { ' - + ' beat.tags = []; ' - + '} ' - + 'if (!beat.tags.contains(params.tag)) { ' - + ' beat.tags.add(params.tag); ' - + '}'; - - return [ - { update: { _id: `beat:${beatId}` } }, - { script: { source: script, params: { tag } } } - ]; - })); - - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - body, - refresh: 'wait_for' - }; - - const response = await callWithRequest('bulk', params); - return get(response, 'items', []) - .map((item, resultIdx) => ({ - status: item.update.status, - result: item.update.result, - idxInRequest: assignments[resultIdx].idxInRequest - })); -} - -function addNonExistentItemAssignmentsToResponse(response, assignments, nonExistentBeatIds, nonExistentTags) { - assignments.forEach(({ beat_id: beatId, tag }, idx) => { - const isBeatNonExistent = nonExistentBeatIds.includes(beatId); - const isTagNonExistent = nonExistentTags.includes(tag); - - if (isBeatNonExistent && isTagNonExistent) { - response.assignments[idx].status = 404; - response.assignments[idx].result = `Beat ${beatId} and tag ${tag} not found`; - } else if (isBeatNonExistent) { - response.assignments[idx].status = 404; - response.assignments[idx].result = `Beat ${beatId} not found`; - } else if (isTagNonExistent) { - response.assignments[idx].status = 404; - response.assignments[idx].result = `Tag ${tag} not found`; - } - }); -} - -function addAssignmentResultsToResponse(response, assignmentResults) { - assignmentResults.forEach(assignmentResult => { - const { idxInRequest, status, result } = assignmentResult; - response.assignments[idxInRequest].status = status; - response.assignments[idxInRequest].result = result; - }); -} - -// TODO: add license check pre-hook -// TODO: write to Kibana audit log file -export function registerAssignTagsToBeatsRoute(server) { - server.route({ - method: 'POST', - path: '/api/beats/agents_tags/assignments', - config: { - validate: { - payload: Joi.object({ - assignments: Joi.array().items(Joi.object({ - beat_id: Joi.string().required(), - tag: Joi.string().required() - })) - }).required() - } - }, - handler: async (request, reply) => { - const callWithRequest = callWithRequestFactory(server, request); - - const { assignments } = request.payload; - const beatIds = uniq(assignments.map(assignment => assignment.beat_id)); - const tags = uniq(assignments.map(assignment => assignment.tag)); - - const response = { - assignments: assignments.map(() => ({ status: null })) - }; - - try { - // Handle assignments containing non-existing beat IDs or tags - const nonExistentBeatIds = await findNonExistentBeatIds(callWithRequest, beatIds); - const nonExistentTags = await findNonExistentTags(callWithRequest, tags); - - addNonExistentItemAssignmentsToResponse(response, assignments, nonExistentBeatIds, nonExistentTags); - - const validAssignments = assignments - .map((assignment, idxInRequest) => ({ - beatId: assignment.beat_id, - tag: assignment.tag, - idxInRequest // so we can add the result of this assignment to the correct place in the response - })) - .filter((assignment, idx) => response.assignments[idx].status === null); - - if (validAssignments.length > 0) { - const assignmentResults = await persistAssignments(callWithRequest, validAssignments); - addAssignmentResultsToResponse(response, assignmentResults); - } - } catch (err) { - return reply(wrapEsError(err)); - } - - reply(response); - } - }); -} diff --git a/x-pack/plugins/beats/server/routes/api/register_create_enrollment_tokens_route.js b/x-pack/plugins/beats/server/routes/api/register_create_enrollment_tokens_route.js deleted file mode 100644 index 87ae30cd0e532..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/register_create_enrollment_tokens_route.js +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 Joi from 'joi'; -import uuid from 'uuid'; -import moment from 'moment'; -import { - get, - flatten -} from 'lodash'; -import { INDEX_NAMES } from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/client'; -import { wrapEsError } from '../../lib/error_wrappers'; - -function persistTokens(callWithRequest, tokens, enrollmentTokensTtlInSeconds) { - const enrollmentTokenExpiration = moment().add(enrollmentTokensTtlInSeconds, 'seconds').toJSON(); - const body = flatten(tokens.map(token => [ - { index: { _id: `enrollment_token:${token}` } }, - { type: 'enrollment_token', enrollment_token: { token, expires_on: enrollmentTokenExpiration } } - ])); - - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - body, - refresh: 'wait_for' - }; - - return callWithRequest('bulk', params); -} - -// TODO: add license check pre-hook -// TODO: write to Kibana audit log file -export function registerCreateEnrollmentTokensRoute(server) { - const DEFAULT_NUM_TOKENS = 1; - const enrollmentTokensTtlInSeconds = server.config().get('xpack.beats.enrollmentTokensTtlInSeconds'); - - server.route({ - method: 'POST', - path: '/api/beats/enrollment_tokens', - config: { - validate: { - payload: Joi.object({ - num_tokens: Joi.number().optional().default(DEFAULT_NUM_TOKENS).min(1) - }).allow(null) - } - }, - handler: async (request, reply) => { - const callWithRequest = callWithRequestFactory(server, request); - const numTokens = get(request, 'payload.num_tokens', DEFAULT_NUM_TOKENS); - - const tokens = []; - while (tokens.length < numTokens) { - tokens.push(uuid.v4().replace(/-/g, "")); - } - - try { - await persistTokens(callWithRequest, tokens, enrollmentTokensTtlInSeconds); - } catch (err) { - return reply(wrapEsError(err)); - } - - const response = { tokens }; - reply(response); - } - }); -} diff --git a/x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js b/x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js deleted file mode 100644 index bad28c0ab9be5..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/register_enroll_beat_route.js +++ /dev/null @@ -1,115 +0,0 @@ -/* - * 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 Joi from 'joi'; -import uuid from 'uuid'; -import moment from 'moment'; -import { - get, - omit -} from 'lodash'; -import { INDEX_NAMES } from '../../../common/constants'; -import { callWithInternalUserFactory } from '../../lib/client'; -import { wrapEsError } from '../../lib/error_wrappers'; - -async function getEnrollmentToken(callWithInternalUser, enrollmentToken) { - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - id: `enrollment_token:${enrollmentToken}`, - ignore: [ 404 ] - }; - - const response = await callWithInternalUser('get', params); - const token = get(response, '_source.enrollment_token', {}); - - // Elasticsearch might return fast if the token is not found. OR it might return fast - // if the token *is* found. Either way, an attacker could using a timing attack to figure - // out whether a token is valid or not. So we introduce a random delay in returning from - // this function to obscure the actual time it took for Elasticsearch to find the token. - const randomDelayInMs = 25 + Math.round(Math.random() * 200); // between 25 and 225 ms - return new Promise(resolve => setTimeout(() => resolve(token), randomDelayInMs)); -} - -function deleteUsedEnrollmentToken(callWithInternalUser, enrollmentToken) { - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - id: `enrollment_token:${enrollmentToken}` - }; - - return callWithInternalUser('delete', params); -} - -function persistBeat(callWithInternalUser, beat) { - const body = { - type: 'beat', - beat - }; - - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - id: `beat:${beat.id}`, - body, - refresh: 'wait_for' - }; - return callWithInternalUser('create', params); -} - -// TODO: add license check pre-hook -// TODO: write to Kibana audit log file -export function registerEnrollBeatRoute(server) { - server.route({ - method: 'POST', - path: '/api/beats/agent/{beatId}', - config: { - validate: { - payload: Joi.object({ - type: Joi.string().required(), - version: Joi.string().required(), - host_name: Joi.string().required() - }).required(), - headers: Joi.object({ - 'kbn-beats-enrollment-token': Joi.string().required() - }).options({ allowUnknown: true }) - }, - auth: false - }, - handler: async (request, reply) => { - const callWithInternalUser = callWithInternalUserFactory(server); - const { beatId } = request.params; - let accessToken; - - try { - const enrollmentToken = request.headers['kbn-beats-enrollment-token']; - const { token, expires_on: expiresOn } = await getEnrollmentToken(callWithInternalUser, enrollmentToken); - if (!token) { - return reply({ message: 'Invalid enrollment token' }).code(400); - } - if (moment(expiresOn).isBefore(moment())) { - return reply({ message: 'Expired enrollment token' }).code(400); - } - - accessToken = uuid.v4().replace(/-/g, ""); - const remoteAddress = request.info.remoteAddress; - await persistBeat(callWithInternalUser, { - ...omit(request.payload, 'enrollment_token'), - id: beatId, - access_token: accessToken, - host_ip: remoteAddress - }); - - await deleteUsedEnrollmentToken(callWithInternalUser, enrollmentToken); - } catch (err) { - return reply(wrapEsError(err)); - } - - const response = { access_token: accessToken }; - reply(response).code(201); - } - }); -} diff --git a/x-pack/plugins/beats/server/routes/api/register_list_beats_route.js b/x-pack/plugins/beats/server/routes/api/register_list_beats_route.js deleted file mode 100644 index b84210988978f..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/register_list_beats_route.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 { - get, - omit -} from "lodash"; -import { INDEX_NAMES } from "../../../common/constants"; -import { callWithRequestFactory } from '../../lib/client'; -import { wrapEsError } from "../../lib/error_wrappers"; - -async function getBeats(callWithRequest) { - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - q: 'type:beat' - }; - - const response = await callWithRequest('search', params); - return get(response, 'hits.hits', []); -} - -// TODO: add license check pre-hook -export function registerListBeatsRoute(server) { - server.route({ - method: 'GET', - path: '/api/beats/agents', - handler: async (request, reply) => { - const callWithRequest = callWithRequestFactory(server, request); - let beats; - - try { - beats = await getBeats(callWithRequest); - } catch (err) { - return reply(wrapEsError(err)); - } - - const response = { - beats: beats.map(beat => omit(beat._source.beat, ['access_token'])) - }; - reply(response); - } - }); -} diff --git a/x-pack/plugins/beats/server/routes/api/register_remove_tags_from_beats_route.js b/x-pack/plugins/beats/server/routes/api/register_remove_tags_from_beats_route.js deleted file mode 100644 index b5e66267b2ea4..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/register_remove_tags_from_beats_route.js +++ /dev/null @@ -1,166 +0,0 @@ -/* - * 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 Joi from 'joi'; -import { - get, - flatten, - uniq -} from 'lodash'; -import { INDEX_NAMES } from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/client'; -import { wrapEsError } from '../../lib/error_wrappers'; - -async function getDocs(callWithRequest, ids) { - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - body: { ids }, - _source: false - }; - - const response = await callWithRequest('mget', params); - return get(response, 'docs', []); -} - -function getBeats(callWithRequest, beatIds) { - const ids = beatIds.map(beatId => `beat:${beatId}`); - return getDocs(callWithRequest, ids); -} - -function getTags(callWithRequest, tags) { - const ids = tags.map(tag => `tag:${tag}`); - return getDocs(callWithRequest, ids); -} - -async function findNonExistentItems(callWithRequest, items, getFn) { - const itemsFromEs = await getFn.call(null, callWithRequest, items); - return itemsFromEs.reduce((nonExistentItems, itemFromEs, idx) => { - if (!itemFromEs.found) { - nonExistentItems.push(items[idx]); - } - return nonExistentItems; - }, []); -} - -function findNonExistentBeatIds(callWithRequest, beatIds) { - return findNonExistentItems(callWithRequest, beatIds, getBeats); -} - -function findNonExistentTags(callWithRequest, tags) { - return findNonExistentItems(callWithRequest, tags, getTags); -} - -async function persistRemovals(callWithRequest, removals) { - const body = flatten(removals.map(({ beatId, tag }) => { - const script = '' - + 'def beat = ctx._source.beat; ' - + 'if (beat.tags != null) { ' - + ' beat.tags.removeAll([params.tag]); ' - + '}'; - - return [ - { update: { _id: `beat:${beatId}` } }, - { script: { source: script, params: { tag } } } - ]; - })); - - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - body, - refresh: 'wait_for' - }; - - const response = await callWithRequest('bulk', params); - return get(response, 'items', []) - .map((item, resultIdx) => ({ - status: item.update.status, - result: item.update.result, - idxInRequest: removals[resultIdx].idxInRequest - })); -} - -function addNonExistentItemRemovalsToResponse(response, removals, nonExistentBeatIds, nonExistentTags) { - removals.forEach(({ beat_id: beatId, tag }, idx) => { - const isBeatNonExistent = nonExistentBeatIds.includes(beatId); - const isTagNonExistent = nonExistentTags.includes(tag); - - if (isBeatNonExistent && isTagNonExistent) { - response.removals[idx].status = 404; - response.removals[idx].result = `Beat ${beatId} and tag ${tag} not found`; - } else if (isBeatNonExistent) { - response.removals[idx].status = 404; - response.removals[idx].result = `Beat ${beatId} not found`; - } else if (isTagNonExistent) { - response.removals[idx].status = 404; - response.removals[idx].result = `Tag ${tag} not found`; - } - }); -} - -function addRemovalResultsToResponse(response, removalResults) { - removalResults.forEach(removalResult => { - const { idxInRequest, status, result } = removalResult; - response.removals[idxInRequest].status = status; - response.removals[idxInRequest].result = result; - }); -} - -// TODO: add license check pre-hook -// TODO: write to Kibana audit log file -export function registerRemoveTagsFromBeatsRoute(server) { - server.route({ - method: 'POST', - path: '/api/beats/agents_tags/removals', - config: { - validate: { - payload: Joi.object({ - removals: Joi.array().items(Joi.object({ - beat_id: Joi.string().required(), - tag: Joi.string().required() - })) - }).required() - } - }, - handler: async (request, reply) => { - const callWithRequest = callWithRequestFactory(server, request); - - const { removals } = request.payload; - const beatIds = uniq(removals.map(removal => removal.beat_id)); - const tags = uniq(removals.map(removal => removal.tag)); - - const response = { - removals: removals.map(() => ({ status: null })) - }; - - try { - // Handle removals containing non-existing beat IDs or tags - const nonExistentBeatIds = await findNonExistentBeatIds(callWithRequest, beatIds); - const nonExistentTags = await findNonExistentTags(callWithRequest, tags); - - addNonExistentItemRemovalsToResponse(response, removals, nonExistentBeatIds, nonExistentTags); - - const validRemovals = removals - .map((removal, idxInRequest) => ({ - beatId: removal.beat_id, - tag: removal.tag, - idxInRequest // so we can add the result of this removal to the correct place in the response - })) - .filter((removal, idx) => response.removals[idx].status === null); - - if (validRemovals.length > 0) { - const removalResults = await persistRemovals(callWithRequest, validRemovals); - addRemovalResultsToResponse(response, removalResults); - } - } catch (err) { - return reply(wrapEsError(err)); - } - - reply(response); - } - }); -} diff --git a/x-pack/plugins/beats/server/routes/api/register_set_tag_route.js b/x-pack/plugins/beats/server/routes/api/register_set_tag_route.js deleted file mode 100644 index 288fcade9929b..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/register_set_tag_route.js +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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 Joi from 'joi'; -import { - get, - uniq, - intersection -} from 'lodash'; -import { - INDEX_NAMES, - CONFIGURATION_BLOCKS -} from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/client'; -import { wrapEsError } from '../../lib/error_wrappers'; - -function validateUniquenessEnforcingTypes(configurationBlocks) { - const types = uniq(configurationBlocks.map(block => block.type)); - - // If none of the types in the given configuration blocks are uniqueness-enforcing, - // we don't need to perform any further validation checks. - const uniquenessEnforcingTypes = intersection(types, CONFIGURATION_BLOCKS.UNIQUENESS_ENFORCING_TYPES); - if (uniquenessEnforcingTypes.length === 0) { - return { isValid: true }; - } - - // Count the number of uniqueness-enforcing types in the given configuration blocks - const typeCountMap = configurationBlocks.reduce((typeCountMap, block) => { - const { type } = block; - if (!uniquenessEnforcingTypes.includes(type)) { - return typeCountMap; - } - - const count = typeCountMap[type] || 0; - return { - ...typeCountMap, - [type]: count + 1 - }; - }, {}); - - // If there is no more than one of any uniqueness-enforcing types in the given - // configuration blocks, we don't need to perform any further validation checks. - if (Object.values(typeCountMap).filter(count => count > 1).length === 0) { - return { isValid: true }; - } - - const message = Object.entries(typeCountMap) - .filter(([, count]) => count > 1) - .map(([type, count]) => `Expected only one configuration block of type '${type}' but found ${count}`) - .join(' '); - - return { - isValid: false, - message - }; -} - -async function validateConfigurationBlocks(configurationBlocks) { - return validateUniquenessEnforcingTypes(configurationBlocks); -} - -async function persistTag(callWithRequest, tag) { - const body = { - type: 'tag', - tag - }; - - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - id: `tag:${tag.id}`, - body, - refresh: 'wait_for' - }; - - const response = await callWithRequest('index', params); - return response.result; -} - -// TODO: add license check pre-hook -// TODO: write to Kibana audit log file -export function registerSetTagRoute(server) { - server.route({ - method: 'PUT', - path: '/api/beats/tag/{tag}', - config: { - validate: { - payload: Joi.object({ - configuration_blocks: Joi.array().items( - Joi.object({ - type: Joi.string().required().valid(Object.values(CONFIGURATION_BLOCKS.TYPES)), - block_yml: Joi.string().required() - }) - ) - }).allow(null) - } - }, - handler: async (request, reply) => { - const callWithRequest = callWithRequestFactory(server, request); - - let result; - try { - const configurationBlocks = get(request, 'payload.configuration_blocks', []); - const { isValid, message } = await validateConfigurationBlocks(configurationBlocks); - if (!isValid) { - return reply({ message }).code(400); - } - - const tag = { - id: request.params.tag, - configuration_blocks: configurationBlocks - }; - result = await persistTag(callWithRequest, tag); - } catch (err) { - return reply(wrapEsError(err)); - } - - reply().code(result === 'created' ? 201 : 200); - } - }); -} diff --git a/x-pack/plugins/beats/server/routes/api/register_update_beat_route.js b/x-pack/plugins/beats/server/routes/api/register_update_beat_route.js deleted file mode 100644 index 5955e65f6bbaf..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/register_update_beat_route.js +++ /dev/null @@ -1,101 +0,0 @@ -/* - * 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 Joi from 'joi'; -import { get } from 'lodash'; -import { INDEX_NAMES } from '../../../common/constants'; -import { callWithInternalUserFactory } from '../../lib/client'; -import { wrapEsError } from '../../lib/error_wrappers'; -import { areTokensEqual } from '../../lib/crypto'; - -async function getBeat(callWithInternalUser, beatId) { - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - id: `beat:${beatId}`, - ignore: [ 404 ] - }; - - const response = await callWithInternalUser('get', params); - if (!response.found) { - return null; - } - - return get(response, '_source.beat'); -} - -function persistBeat(callWithInternalUser, beat) { - const body = { - type: 'beat', - beat - }; - - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - id: `beat:${beat.id}`, - body, - refresh: 'wait_for' - }; - return callWithInternalUser('index', params); -} - -// TODO: add license check pre-hook -// TODO: write to Kibana audit log file (include who did the verification as well) -export function registerUpdateBeatRoute(server) { - server.route({ - method: 'PUT', - path: '/api/beats/agent/{beatId}', - config: { - validate: { - payload: Joi.object({ - type: Joi.string(), - version: Joi.string(), - host_name: Joi.string(), - ephemeral_id: Joi.string(), - local_configuration_yml: Joi.string(), - metadata: Joi.object() - }).required(), - headers: Joi.object({ - 'kbn-beats-access-token': Joi.string().required() - }).options({ allowUnknown: true }) - }, - auth: false - }, - handler: async (request, reply) => { - const callWithInternalUser = callWithInternalUserFactory(server); - const { beatId } = request.params; - - try { - const beat = await getBeat(callWithInternalUser, beatId); - if (beat === null) { - return reply({ message: 'Beat not found' }).code(404); - } - - const isAccessTokenValid = areTokensEqual(beat.access_token, request.headers['kbn-beats-access-token']); - if (!isAccessTokenValid) { - return reply({ message: 'Invalid access token' }).code(401); - } - - const isBeatVerified = beat.hasOwnProperty('verified_on'); - if (!isBeatVerified) { - return reply({ message: 'Beat has not been verified' }).code(400); - } - - const remoteAddress = request.info.remoteAddress; - await persistBeat(callWithInternalUser, { - ...beat, - ...request.payload, - host_ip: remoteAddress - }); - } catch (err) { - return reply(wrapEsError(err)); - } - - reply().code(204); - } - }); -} diff --git a/x-pack/plugins/beats/server/routes/api/register_verify_beats_route.js b/x-pack/plugins/beats/server/routes/api/register_verify_beats_route.js deleted file mode 100644 index b2113029224a5..0000000000000 --- a/x-pack/plugins/beats/server/routes/api/register_verify_beats_route.js +++ /dev/null @@ -1,143 +0,0 @@ -/* - * 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 Joi from 'joi'; -import moment from 'moment'; -import { - get, - flatten -} from 'lodash'; -import { INDEX_NAMES } from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/client'; -import { wrapEsError } from '../../lib/error_wrappers'; - -async function getBeats(callWithRequest, beatIds) { - const ids = beatIds.map(beatId => `beat:${beatId}`); - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - body: { ids }, - _sourceInclude: [ 'beat.id', 'beat.verified_on' ] - }; - - const response = await callWithRequest('mget', params); - return get(response, 'docs', []); -} - -async function verifyBeats(callWithRequest, beatIds) { - if (!Array.isArray(beatIds) || (beatIds.length === 0)) { - return []; - } - - const verifiedOn = moment().toJSON(); - const body = flatten(beatIds.map(beatId => [ - { update: { _id: `beat:${beatId}` } }, - { doc: { beat: { verified_on: verifiedOn } } } - ])); - - const params = { - index: INDEX_NAMES.BEATS, - type: '_doc', - body, - refresh: 'wait_for' - }; - - const response = await callWithRequest('bulk', params); - return get(response, 'items', []); -} - -function findNonExistentBeatIds(beatsFromEs, beatIdsFromRequest) { - return beatsFromEs.reduce((nonExistentBeatIds, beatFromEs, idx) => { - if (!beatFromEs.found) { - nonExistentBeatIds.push(beatIdsFromRequest[idx]); - } - return nonExistentBeatIds; - }, []); -} - -function findAlreadyVerifiedBeatIds(beatsFromEs) { - return beatsFromEs - .filter(beat => beat.found) - .filter(beat => beat._source.beat.hasOwnProperty('verified_on')) - .map(beat => beat._source.beat.id); -} - -function findToBeVerifiedBeatIds(beatsFromEs) { - return beatsFromEs - .filter(beat => beat.found) - .filter(beat => !beat._source.beat.hasOwnProperty('verified_on')) - .map(beat => beat._source.beat.id); -} - -function findVerifiedBeatIds(verifications, toBeVerifiedBeatIds) { - return verifications.reduce((verifiedBeatIds, verification, idx) => { - if (verification.update.status === 200) { - verifiedBeatIds.push(toBeVerifiedBeatIds[idx]); - } - return verifiedBeatIds; - }, []); -} - -// TODO: add license check pre-hook -// TODO: write to Kibana audit log file -export function registerVerifyBeatsRoute(server) { - server.route({ - method: 'POST', - path: '/api/beats/agents/verify', - config: { - validate: { - payload: Joi.object({ - beats: Joi.array({ - id: Joi.string().required() - }).min(1) - }).required() - } - }, - handler: async (request, reply) => { - const callWithRequest = callWithRequestFactory(server, request); - - const beats = [...request.payload.beats]; - const beatIds = beats.map(beat => beat.id); - - let nonExistentBeatIds; - let alreadyVerifiedBeatIds; - let verifiedBeatIds; - - try { - const beatsFromEs = await getBeats(callWithRequest, beatIds); - - nonExistentBeatIds = findNonExistentBeatIds(beatsFromEs, beatIds); - alreadyVerifiedBeatIds = findAlreadyVerifiedBeatIds(beatsFromEs); - const toBeVerifiedBeatIds = findToBeVerifiedBeatIds(beatsFromEs); - - const verifications = await verifyBeats(callWithRequest, toBeVerifiedBeatIds); - verifiedBeatIds = findVerifiedBeatIds(verifications, toBeVerifiedBeatIds); - - } catch (err) { - return reply(wrapEsError(err)); - } - - beats.forEach(beat => { - if (nonExistentBeatIds.includes(beat.id)) { - beat.status = 404; - beat.result = 'not found'; - } else if (alreadyVerifiedBeatIds.includes(beat.id)) { - beat.status = 200; - beat.result = 'already verified'; - } else if (verifiedBeatIds.includes(beat.id)) { - beat.status = 200; - beat.result = 'verified'; - } else { - beat.status = 400; - beat.result = 'not verified'; - } - }); - - const response = { beats }; - reply(response); - } - }); -} diff --git a/x-pack/plugins/beats/server/utils/README.md b/x-pack/plugins/beats/server/utils/README.md new file mode 100644 index 0000000000000..8a6a27aa29867 --- /dev/null +++ b/x-pack/plugins/beats/server/utils/README.md @@ -0,0 +1 @@ +Utils should be data processing functions and other tools.... all in all utils is basicly everything that is not an adaptor, or presenter and yet too much to put in a lib. \ No newline at end of file diff --git a/x-pack/plugins/beats/server/lib/error_wrappers/index.js b/x-pack/plugins/beats/server/utils/error_wrappers/index.ts similarity index 100% rename from x-pack/plugins/beats/server/lib/error_wrappers/index.js rename to x-pack/plugins/beats/server/utils/error_wrappers/index.ts diff --git a/x-pack/plugins/beats/server/lib/error_wrappers/wrap_es_error.test.js b/x-pack/plugins/beats/server/utils/error_wrappers/wrap_es_error.test.js similarity index 90% rename from x-pack/plugins/beats/server/lib/error_wrappers/wrap_es_error.test.js rename to x-pack/plugins/beats/server/utils/error_wrappers/wrap_es_error.test.js index ec7338262844a..03b04a2ef61d2 100644 --- a/x-pack/plugins/beats/server/lib/error_wrappers/wrap_es_error.test.js +++ b/x-pack/plugins/beats/server/utils/error_wrappers/wrap_es_error.test.js @@ -8,7 +8,6 @@ import { wrapEsError } from './wrap_es_error'; describe('wrap_es_error', () => { describe('#wrapEsError', () => { - let originalError; beforeEach(() => { originalError = new Error('I am an error'); @@ -34,7 +33,9 @@ describe('wrap_es_error', () => { const wrappedError = wrapEsError(securityError); expect(wrappedError.isBoom).to.be(true); - expect(wrappedError.message).to.be('Insufficient user permissions for managing Logstash pipelines'); + expect(wrappedError.message).to.be( + 'Insufficient user permissions for managing Logstash pipelines' + ); }); }); }); diff --git a/x-pack/plugins/beats/server/lib/error_wrappers/wrap_es_error.js b/x-pack/plugins/beats/server/utils/error_wrappers/wrap_es_error.ts similarity index 79% rename from x-pack/plugins/beats/server/lib/error_wrappers/wrap_es_error.js rename to x-pack/plugins/beats/server/utils/error_wrappers/wrap_es_error.ts index d2abcab5c37dd..50ffbcb4a10c9 100644 --- a/x-pack/plugins/beats/server/lib/error_wrappers/wrap_es_error.js +++ b/x-pack/plugins/beats/server/utils/error_wrappers/wrap_es_error.ts @@ -13,10 +13,12 @@ import Boom from 'boom'; * @param err Object ES error * @return Object Boom error response */ -export function wrapEsError(err) { +export function wrapEsError(err: any) { const statusCode = err.statusCode; if (statusCode === 403) { - return Boom.forbidden('Insufficient user permissions for managing Beats configuration'); + return Boom.forbidden( + 'Insufficient user permissions for managing Beats configuration' + ); } return Boom.wrap(err, err.statusCode); } diff --git a/x-pack/plugins/beats/server/utils/find_non_existent_items.ts b/x-pack/plugins/beats/server/utils/find_non_existent_items.ts new file mode 100644 index 0000000000000..53e4066acc879 --- /dev/null +++ b/x-pack/plugins/beats/server/utils/find_non_existent_items.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export function findNonExistentItems(items: any, requestedItems: any) { + return items.reduce((nonExistentItems: any, item: any, idx: any) => { + if (!item.found) { + nonExistentItems.push(requestedItems[idx]); + } + return nonExistentItems; + }, []); +} diff --git a/x-pack/plugins/beats/server/lib/index_template/beats_template.json b/x-pack/plugins/beats/server/utils/index_templates/beats_template.json similarity index 97% rename from x-pack/plugins/beats/server/lib/index_template/beats_template.json rename to x-pack/plugins/beats/server/utils/index_templates/beats_template.json index 9f912f19b2a8d..0d00abbc5d759 100644 --- a/x-pack/plugins/beats/server/lib/index_template/beats_template.json +++ b/x-pack/plugins/beats/server/utils/index_templates/beats_template.json @@ -1,7 +1,5 @@ { - "index_patterns": [ - ".management-beats" - ], + "index_patterns": [".management-beats"], "version": 65000, "settings": { "index": { diff --git a/x-pack/plugins/beats/server/lib/index_template/index.js b/x-pack/plugins/beats/server/utils/index_templates/index.ts similarity index 73% rename from x-pack/plugins/beats/server/lib/index_template/index.js rename to x-pack/plugins/beats/server/utils/index_templates/index.ts index 04128e46ff0ea..eeaef7a68d49f 100644 --- a/x-pack/plugins/beats/server/lib/index_template/index.js +++ b/x-pack/plugins/beats/server/utils/index_templates/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { installIndexTemplate } from './install_index_template'; +import beatsIndexTemplate from './beats_template.json'; +export { beatsIndexTemplate }; diff --git a/x-pack/plugins/beats/server/utils/polyfills.ts b/x-pack/plugins/beats/server/utils/polyfills.ts new file mode 100644 index 0000000000000..5291e2c72be7d --- /dev/null +++ b/x-pack/plugins/beats/server/utils/polyfills.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export const entries = (obj: any) => { + const ownProps = Object.keys(obj); + let i = ownProps.length; + const resArray = new Array(i); // preallocate the Array + + while (i--) { + resArray[i] = [ownProps[i], obj[ownProps[i]]]; + } + + return resArray; +}; diff --git a/x-pack/plugins/beats/server/utils/wrap_request.ts b/x-pack/plugins/beats/server/utils/wrap_request.ts new file mode 100644 index 0000000000000..a29f9055f3688 --- /dev/null +++ b/x-pack/plugins/beats/server/utils/wrap_request.ts @@ -0,0 +1,24 @@ +/* + * 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 { FrameworkRequest, WrappableRequest } from '../lib/lib'; + +export const internalFrameworkRequest = Symbol('internalFrameworkRequest'); + +export function wrapRequest( + req: InternalRequest +): FrameworkRequest { + const { params, payload, query, headers, info } = req; + + return { + [internalFrameworkRequest]: req, + headers, + info, + params, + payload, + query, + }; +} diff --git a/x-pack/plugins/beats/tsconfig.json b/x-pack/plugins/beats/tsconfig.json new file mode 100644 index 0000000000000..4082f16a5d91c --- /dev/null +++ b/x-pack/plugins/beats/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/x-pack/plugins/beats/server/lib/crypto/index.js b/x-pack/plugins/beats/types/json.t.ts similarity index 77% rename from x-pack/plugins/beats/server/lib/crypto/index.js rename to x-pack/plugins/beats/types/json.t.ts index 31fa5de67b2ca..46af99f7f740b 100644 --- a/x-pack/plugins/beats/server/lib/crypto/index.js +++ b/x-pack/plugins/beats/types/json.t.ts @@ -4,4 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { areTokensEqual } from './are_tokens_equal'; +declare module '*.json' { + const value: any; + export default value; +} diff --git a/x-pack/plugins/beats/wallaby.js b/x-pack/plugins/beats/wallaby.js new file mode 100644 index 0000000000000..c20488d35cfb6 --- /dev/null +++ b/x-pack/plugins/beats/wallaby.js @@ -0,0 +1,27 @@ +/* + * 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. + */ + +module.exports = function (wallaby) { + return { + debug: true, + files: [ + '../../tsconfig.json', + //'plugins/beats/public/**/*.+(js|jsx|ts|tsx|json|snap|css|less|sass|scss|jpg|jpeg|gif|png|svg)', + 'server/**/*.+(js|jsx|ts|tsx|json|snap|css|less|sass|scss|jpg|jpeg|gif|png|svg)', + 'common/**/*.+(js|jsx|ts|tsx|json|snap|css|less|sass|scss|jpg|jpeg|gif|png|svg)', + ], + + tests: ['**/*.test.ts'], + env: { + type: 'node', + runner: 'node', + }, + testFramework: 'jest', + compilers: { + '**/*.ts?(x)': wallaby.compilers.typeScript({ module: 'commonjs' }), + }, + }; +}; diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index ed1053228676f..803b25f38cd9a 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -118,6 +118,10 @@ url-join "^4.0.0" ws "^4.1.0" +"@types/boom@^4.3.8": + version "4.3.10" + resolved "https://registry.yarnpkg.com/@types/boom/-/boom-4.3.10.tgz#39dad8c0614c26b91ef016a57d7eee4ffe4f8a25" + "@types/delay@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/delay/-/delay-2.0.1.tgz#61bcf318a74b61e79d1658fbf054f984c90ef901" @@ -138,6 +142,12 @@ dependencies: "@types/node" "*" +"@types/hapi@15.0.1": + version "15.0.1" + resolved "https://registry.yarnpkg.com/@types/hapi/-/hapi-15.0.1.tgz#919e1d3a9160a080c9fdefaccc892239772e1258" + dependencies: + "@types/node" "*" + "@types/is-stream@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1" @@ -148,6 +158,14 @@ version "22.2.3" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-22.2.3.tgz#0157c0316dc3722c43a7b71de3fdf3acbccef10d" +"@types/joi@^10.4.0": + version "10.6.2" + resolved "https://registry.yarnpkg.com/@types/joi/-/joi-10.6.2.tgz#0e7d632fe918c337784e87b16c7cc0098876179a" + +"@types/lodash@^3.10.0": + version "3.10.2" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-3.10.2.tgz#c1fbda1562ef5603c8192fe1fe65b017849d5873" + "@types/loglevel@^1.5.3": version "1.5.3" resolved "https://registry.yarnpkg.com/@types/loglevel/-/loglevel-1.5.3.tgz#adfce55383edc5998a2170ad581b3e23d6adb5b8" @@ -174,9 +192,9 @@ dependencies: "@types/retry" "*" -"@types/pngjs@^3.3.1": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.1.tgz#47d97bd29dd6372856050e9e5e366517dd1ba2d8" +"@types/pngjs@^3.3.0": + version "3.3.2" + resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.2.tgz#8ed3bd655ab3a92ea32ada7a21f618e63b93b1d4" dependencies: "@types/node" "*" @@ -188,6 +206,12 @@ version "0.8.2" resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-0.8.2.tgz#1181ecbe1d97b7034e0ea1e35e62e86cc26b422d" +"@types/uuid@^3.4.3": + version "3.4.3" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.3.tgz#121ace265f5569ce40f4f6d0ff78a338c732a754" + dependencies: + "@types/node" "*" + "@types/ws@^4.0.1": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-4.0.2.tgz#b29037627dd7ba31ec49a4f1584840422efb856f" diff --git a/yarn.lock b/yarn.lock index 348d0dafe3790..c6eeaf8861466 100644 --- a/yarn.lock +++ b/yarn.lock @@ -523,6 +523,12 @@ version "0.8.2" resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-0.8.2.tgz#1181ecbe1d97b7034e0ea1e35e62e86cc26b422d" +"@types/uuid@^3.4.3": + version "3.4.3" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.3.tgz#121ace265f5569ce40f4f6d0ff78a338c732a754" + dependencies: + "@types/node" "*" + "@types/ws@^4.0.1": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-4.0.2.tgz#b29037627dd7ba31ec49a4f1584840422efb856f" @@ -12186,7 +12192,7 @@ source-map-support@^0.5.0: dependencies: source-map "^0.6.0" -source-map-support@^0.5.3, source-map-support@^0.5.5: +source-map-support@^0.5.5, source-map-support@^0.5.6: version "0.5.6" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.6.tgz#4435cee46b1aab62b8e8610ce60f788091c51c13" dependencies: @@ -13163,17 +13169,16 @@ ts-loader@^3.5.0: micromatch "^3.1.4" semver "^5.0.1" -ts-node@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-6.0.3.tgz#28bf74bcad134fad17f7469dad04638ece03f0f4" +ts-node@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-6.1.1.tgz#19607140acb06150441fcdb61be11f73f7b6657e" dependencies: arrify "^1.0.0" - chalk "^2.3.0" diff "^3.1.0" make-error "^1.1.1" minimist "^1.2.0" mkdirp "^0.5.1" - source-map-support "^0.5.3" + source-map-support "^0.5.6" yn "^2.0.0" tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1: