From da98c4d2f06f0b0284cb7b464b3b2fe9f77c5a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Barto=C5=A1?= <bartos@cesnet.cz> Date: Thu, 5 Mar 2020 11:50:04 +0100 Subject: [PATCH] Possibility to add Observables, rewritten to use React --- thehive_button/package.json | 5 +- thehive_button/public/main.js | 106 +++-- thehive_button/public/vis_controller.js | 585 ++++++++++++++++++------ thehive_button/server/routes/newcase.js | 98 +++- 4 files changed, 613 insertions(+), 181 deletions(-) diff --git a/thehive_button/package.json b/thehive_button/package.json index 0373188..b513a50 100644 --- a/thehive_button/package.json +++ b/thehive_button/package.json @@ -1,10 +1,10 @@ { "name": "thehive_button", - "version": "0.2.0", + "version": "0.3.0", "description": "Visualisation plugin which creates a simple button to create a new case in The Hive.", "main": "index.js", "kibana": { - "version": "7.2.2" + "version": "7.2.2", }, "scripts": { "lint": "eslint .", @@ -13,7 +13,6 @@ }, "dependencies": { "request": "^2.88.0", - "jquery-ui": "1" }, "devDependencies": { "@elastic/eslint-config-kibana": "link:../../packages/eslint-config-kibana", diff --git a/thehive_button/public/main.js b/thehive_button/public/main.js index 96c3bc7..1361795 100644 --- a/thehive_button/public/main.js +++ b/thehive_button/public/main.js @@ -1,9 +1,12 @@ -import './vis.less'; -import optionsTemplate from './options_template.html'; +//import './vis.less'; +//import optionsTemplate from './options_template.html'; import { THEHIVE_API_KEY, THEHIVE_URL, THEHIVE_OWNER } from './env'; -import { VisController } from './vis_controller'; +//import { VisController } from './vis_controller'; +import { TheHiveButtonVisComponent } from './vis_controller'; + import { VisFactoryProvider } from 'ui/vis/vis_factory'; import { VisTypesRegistryProvider } from 'ui/registry/vis_types'; +import { Schemas } from 'ui/vis/editors/default/schemas'; import { Status } from 'ui/vis/update_status'; function TheHiveButtonVisProvider(Private) { @@ -12,62 +15,83 @@ function TheHiveButtonVisProvider(Private) { console.log("default URL:", THEHIVE_URL); console.log("default API key:", THEHIVE_API_KEY); - return VisFactory.createBaseVisualization({ + return VisFactory.createReactVisualization({ name: 'thehive_button', title: 'The Hive Case', icon: 'alert', description: 'A button to create a new Case in The Hive.', - visualization: VisController, - requiresUpdateStatus: [Status.PARAMS, Status.RESIZE, Status.UI_STATE], + //requiresUpdateStatus: [Status.PARAMS, Status.RESIZE, Status.UI_STATE], visConfig: { + component: TheHiveButtonVisComponent, defaults: { // add default parameters url: THEHIVE_URL, apikey: THEHIVE_API_KEY, owner: THEHIVE_OWNER, - }, + } }, editor: 'default', // editor: MyEditorController, editorConfig: { -// optionsTemplate: '<div>TEST</div>', - optionsTemplate: optionsTemplate + /*collections: { + colorSchemas: [ + {id: "test1", label: "Test 1"}, + {id: "test2", label: "Test 2"}, + ], + },*/ + optionsTemplate: '<div>TEST</div>', +// optionsTemplate: '<thehivebutton-vis-params></thehivebutton-vis-params>', + schemas: new Schemas([ + // TODO all this (metric and bucket "terms") could be pre-defind + // (just a selection of fields to read Observables from would be nice) + // But I don't know how and where to pass the values manually. + /* Allowed params (from /src/legacy/ui/public/vis/editors/default/schemas.d.ts) + aggFilter: string | string[]; + editor: boolean | string; + group: AggGroupNames; + max: number; + min: number; + name: string; + params: AggParam[]; + title: string; + */ + { + group: 'metrics', + name: 'metric', + title: "Metric (not used)", + min: 1, + max: 1, + aggFilter: 'count', + defaults: [ + { + type: 'count', + schema: 'metric', + }, + ], + }, + { + group: 'buckets', + name: 'group', + title: 'Observables', + min: 1, + max: 1, + aggFilter: 'terms',//['!geohash_grid', '!geotile_grid', '!filter'], +// defaults: [ +// { +// type: 'terms', +// //schema: 'group', +// field: 'ip', +// size: 100, +// } +// ] + }, + ]), }, - requestHandler: 'none', - responseHandler: 'none', + //requestHandler: 'courier', // default + //responseHandler: 'default', // return data as a table, see https://www.elastic.co/guide/en/kibana/7.2/development-visualization-response-handlers.html }); } - -// UNUSED -/*class MyEditorController { - constructor(el, vis) { - this.el = el; - this.vis = vis; - //this.config = vis.type.editorConfig; - - this.container = document.createElement('div'); - this.container.className = 'myvis-config-container-div'; - this.el.appendChild(this.container); - } - - async render(visData) { - console.log("MyEditorController render(), config:", this.vis); -// console.log(this.vis.isEditorMode); -// console.log(this.vis.getUiState); -// console.log(this.vis.getState); -// console.log(this.vis.params); - this.container.innerHTML = optionsTemplate; -// this.vis.updateState(); - this.vis.dirty = true; - return 'done rendering'; - } - -// destroy() { -// console.log('destroying'); -// } -}*/ - // register the provider with the visTypes registry VisTypesRegistryProvider.register(TheHiveButtonVisProvider); diff --git a/thehive_button/public/vis_controller.js b/thehive_button/public/vis_controller.js index b0bd341..2ddeb1f 100644 --- a/thehive_button/public/vis_controller.js +++ b/thehive_button/public/vis_controller.js @@ -1,135 +1,434 @@ -import { Status } from 'ui/vis/update_status'; +//import { Status } from 'ui/vis/update_status'; import chrome from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; -import case_form from './case_form.html'; - -import React from 'react'; - -import $ from 'jquery'; -import 'jquery-ui/themes/base/core.css'; -import 'jquery-ui/themes/base/theme.css'; -import 'jquery-ui/themes/base/resizable.css'; -import 'jquery-ui/themes/base/dialog.css'; -import 'jquery-ui/ui/core'; -import 'jquery-ui/ui/widgets/resizable'; -import 'jquery-ui/ui/widgets/dialog'; +//import vis_template from './vis_template.html'; +import React, { Component } from 'react'; import { EuiButton, -// EuiPanel, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiTextArea, + EuiSuperSelect, + EuiBasicTable, + EuiCheckbox, + makeId, } from '@elastic/eui'; -function createTheHiveButton(vis) { - // Create the button - var button = document.createElement('button'); - button.className = 'euiButton euiButton--danger euiButton--fill'; - button.innerHTML = '<span class="euiButton__content"><span class="euiButton__text" title="Create new Case in The Hive">Create new Case</span></span>'; - button.addEventListener('click', hiveButtonOnClick); - button._vis = vis; // store reference to 'vis' to the element -// var button2 = <EuiButton fill iconType="alert" color="danger" onClick={(evt) => hiveButtonOnClick(evt, vis.params)}>Create new Case</EuiButton>; -// button2.setState({vis: vis}); - - // Create the dialog to specify Case parameters - var dialog = document.createElement("div"); - dialog.innerHTML = case_form; // content imported from ext. file - $(dialog).dialog({ - autoOpen: false, - title: "Create a new Case ...", - width: 600, - resizable: true, - buttons: [ +// ********** React components ********** + +// Main React component - the root of visualization +export class TheHiveButtonVisComponent extends Component { + render() { + //console.log(this.props); + let ips = []; + if (this.props.visData) { + const first_col_id = this.props.visData.columns[0].id; + ips = this.props.visData.rows.map(row => row[first_col_id]); + //console.log(ips); + } + return ( + <div> + <NewCaseButton params={this.props.vis.params} observables={ips} /> + </div> + ); + } + + componentDidMount() { + this.props.renderComplete(); + } + + componentDidUpdate() { + this.props.renderComplete(); + } +} + +// Button to show the pop-up window (modal) +// Props: +// .params - visualization parameters (from vis.params) +// .observables - list of IP addrsses or other observables to add to the Case +class NewCaseButton extends Component { + + constructor(props) { + super(props); + this.state = { + isModalVisible: false, + }; + // Each handler function in a class (method) must be "binded" this way + this.closeModal = this.closeModal.bind(this); + this.showModal = this.showModal.bind(this); + } + + closeModal() { + this.setState({ isModalVisible: false }); + } + + showModal() { + this.setState({ isModalVisible: true }); + } + + render() { + let modal; + if (this.state.isModalVisible) { + modal = <ModalContent close={this.closeModal} params={this.props.params} observables={this.props.observables} />; + } + return ( + <div> + <EuiButton fill iconType="alert" color="danger" onClick={this.showModal}>Create new Case ...</EuiButton> + {modal} + </div> + ); + } +} + +// The popup window with a form +// Props: +// .close - function to close the Modal +// .params - visualization parameters (from vis.params) +// .observables - list of IP addrsses or other observables to add to the Case +class ModalContent extends Component { + constructor(props) { + super(props); + const observables = props.observables; + const n_obs = observables.length; + + // Initialize state (all changeable case parameters) + // TODO: move state up to the button, so it persists when modal is closed (and add explicit reset button + reset on submit) + this.state = { + title: "", + description: "\n\n--\nCreated from Kibana", + severity: "2", // medium + tlp: "2", // amber + tags: [], + // An array of values for each editable part of observables, prefilled with default values + obsDescrs: new Array(n_obs).fill(""), + obsTLPs: new Array(n_obs).fill(2), + obsIOCs: new Array(n_obs).fill(false), + obsTags: new Array(n_obs).fill([]), + obsSelected: new Array(), // list of indices of selected observables + }; + + // "Select" options + this.severityOptions = [ + {value: "1", inputDisplay: "low"}, + {value: "2", inputDisplay: "medium"}, + {value: "3", inputDisplay: "high"}, + ]; + this.tlpOptions = [ + {value: "0", inputDisplay: "white"}, + {value: "1", inputDisplay: "green"}, + {value: "2", inputDisplay: "amber"}, + {value: "3", inputDisplay: "red"}, + ]; + + // Table column definition + this.columns = [ { - text: "Cancel", - click: function() { - $( this ).dialog( "close" ); - } + field: "id", + name: "Observable", }, { - text: "Submit", - click: function(evt) { - hiveButtonSubmit(dialog, evt.currentTarget); - } - } + field: "descr", + name: "Description", + description: "Description of the observable in the context of the case", + render: (value, item) => (<EuiFieldText + value={item.descr} + onChange={(e) => this.onChangeDescr(e, item.i)} + disabled={!item.selected} + />) + }, + /*{ + field: "tlp", + name: "TLP", + dataType: "number", + // TODO render and process changes + },*/ + { + field: "ioc", + name: "Is IOC", + dataType: "boolean", + description: "Indicates if the observable is an IOC", + render: (value, item) => (<EuiCheckbox + id={"ioc-checkbox-"+item.id} + checked={item.ioc} + onChange={(e) => this.onChangeIOC(e, item.i)} + disabled={!item.selected} + />) + }, + /*{ + field: "tags", + name: "Tags", + // TODO render and process changes + },*/ ] - }); - // add reference to visualisation parameters to the dialog element - dialog._params = button._vis.params; - - // add reference to newly created dialog to the button element - button._dialog_elem = dialog; - return button; -} - -function hiveButtonOnClick(evt) { - // Button click -> just open the dialog - var button = evt.currentTarget; - $(button._dialog_elem).dialog("open"); - return false; -} + + // Each handler function in a class (method) must be "bind" this way + this.submitCase = this.submitCase.bind(this); + this.onChangeDescr = this.onChangeDescr.bind(this); + this.onChangeTLP = this.onChangeTLP.bind(this); + this.onChangeIOC = this.onChangeIOC.bind(this); + this.onChangeTags = this.onChangeTags.bind(this); + this.onSelectionChange = this.onSelectionChange.bind(this); + } -function hiveButtonSubmit(dialog, submit_button) { - var params = dialog._params; - console.log("Submit", submit_button, params); + // Event handlers - change observable parameters + onChangeDescr(evt, rowindex) { + const val = evt.target.value; + this.setState((state, props) => { + // Copy old obsDescrs, update the corresponding item and pass as new state + let descrs = [...state.obsDescrs]; + descrs[rowindex] = val; + return {obsDescrs: descrs}; + }); + } + onChangeTLP(evt, rowindex) { // TODO + const val = evt.target.value; + this.setState((state, props) => { + // Copy old obsTLPs, update the corresponding item and pass as new state + let tlps = [...state.obsTLPs]; + tlps[rowindex] = val; + return {obsTLPs: tlps}; + }); + } + onChangeIOC(evt, rowindex) { + const val = evt.target.checked; + this.setState((state, props) => { + // Copy old obsIOCs, update the corresponding item and pass as new state + let iocs = [...state.obsIOCs]; + iocs[rowindex] = val; + return {obsIOCs: iocs}; + }); + } + onChangeTags(evt, rowindex) { // TODO + const val = evt.target.value; + this.setState((state, props) => { + // Copy old obsTags, update the corresponding item and pass as new state + let tags = [...state.obsTags]; + tags[rowindex] = val; + return {obsTags: tags}; + }); + } - // load data from the <form> - var form = $("form", dialog); - console.log(form); - var title = $('input[name="case-title"]', form).val(); - var descr = $('textarea[name="case-descr"]', form).val(); - var severity = 2; - var start_date = null; - var owner = params.owner; - var flag = false; - var tlp = 2; - var tags = []; - - // check validity - if (title == "") { - toastNotifications.addDanger("ERROR: Title cannot be empty"); - return false; + // Event handler - a row is (de)selected + onSelectionChange(selectedItems) { + // Extract indices from the items and store them into state + const selectedIndices = selectedItems.map(item => item.i); + this.setState({obsSelected: selectedIndices}); } + + // Main render function + // TODO should't it be easier to simply store whole table_data in State? + // everything has to be re-rendered on any state change anyway + render() { + const obs = this.props.observables; + // Create data for the observables table + let table_data = new Array(obs.length); + for (let i = 0; i < obs.length; i++) { + table_data[i] = { + id: obs[i], + descr: this.state.obsDescrs[i], + tlp: this.state.obsTLPs[i], + ioc: this.state.obsIOCs[i], + tags: this.state.obsTags[i], + // auxiliary fields, not shown in table: + i: i, // row index + selected: this.state.obsSelected.includes(i), + }; + } + //console.log("render(): Table data:", table_data); + + return ( + <EuiOverlayMask> + <EuiModal onClose={this.props.close} maxWidth={false} initialFocus="[name=title]"> + <EuiModalHeader> + <EuiModalHeaderTitle>Create a new case in The Hive</EuiModalHeaderTitle> + </EuiModalHeader> - // disable the button to prevent multiple submits - submit_button.innerHTML = "(working...)"; - submit_button.setAttribute("disabled", true); + <EuiModalBody> + <EuiForm style={{width: "800px"}}> + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <EuiFormRow label="Title" fullWidth> + <EuiFieldText name="title" value={this.state.title} onChange={(e) => this.setState({title: e.target.value})} required={true} fullWidth /> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFormRow label="Severity"> + <EuiSuperSelect + options={this.severityOptions} + valueOfSelected={this.state.severity} + onChange={(val) => this.setState({severity: val})} + /> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFormRow label="TLP"> + <EuiSuperSelect + prepend="TLP" + options={this.tlpOptions} + valueOfSelected={this.state.tlp} + onChange={(val) => this.setState({tlp: val})} + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFormRow label="Description" fullWidth> + <EuiTextArea + defaultValue={this.state.description} + onChange={(e) => this.setState({description: e.target.value})} + rows={4} + fullWidth + /> + </EuiFormRow> + + <EuiTitle size="s"><h3>Add observables from current query ...</h3></EuiTitle> + <EuiBasicTable + columns={this.columns} + items={table_data} + itemId={(item) => item.id} + selection={ {onSelectionChange: this.onSelectionChange} } + noItemsMessage="No observables found" + rowProps={{ + // Hack to allow selection by clicking anywhere in the table row + // (except input elements) + onClick: (e) => { + if (e.target.tagName != "INPUT") { + // simulate click on the first checkbox in the row to (de)select the row + e.currentTarget.querySelector("input").click(); + e.currentTarget.blur(); // without this the focus remains on the row after click (results in different color) + } + }, + tabIndex: "-1", // prevents focus on row by keyboard navigation + }} + /> + </EuiForm> + </EuiModalBody> + <EuiModalFooter> + <EuiButtonEmpty onClick={this.props.close}>Cancel</EuiButtonEmpty> + <EuiButton onClick={this.submitCase} fill>Create Case</EuiButton> + </EuiModalFooter> + </EuiModal> + </EuiOverlayMask> + ); + } - createHiveCase(params.url, params.apikey, title, descr, severity, start_date, owner, flag, tlp, tags) - .then(function(value) { - if ('error' in value) { - // Error contacting The Hive - console.log("createHiveCase() ERROR:", value.error); - toastNotifications.addDanger("ERROR: " + value.error); - } - else { - // Success - show notification and open the Case in new tab - console.log("createHiveCase() completed:", value); - const case_url = params.url + "index.html#/case/" + value.id + "/details"; - toastNotifications.add({ - title: "Case created", - color: "success", - iconType: "checkInCircleFilled", - text: ( - <div> - <p><b><a href={case_url} target="_blank">Edit the new Case</a></b></p> - </div> - ), - }); - window.open(case_url, '_blank'); - } - - // re-enable the button - submit_button.innerHTML = "Submit"; - submit_button.removeAttribute("disabled"); - - // close the dialog - $(dialog).dialog( "close" ); - }); - return false; + submitCase(evt) { + const params = this.props.params; + + // Get case parameters + const title = this.state.title; + const descr = this.state.description; + const severity = parseInt(this.state.severity); + const start_date = null; + const owner = params.owner; + const flag = false; + const tlp = parseInt(this.state.tlp); + const tags = this.state.tags; + + if (!title) { + toastNotifications.addDanger("Title can't be empty"); + return; + } + + // Get list of selected observables and their params + let observables = []; + let selectionIndices = [...this.state.obsSelected]; // make a copy + selectionIndices.sort(); + for (let i = 0; i < selectionIndices.length; i++) { + const j = selectionIndices[i]; // index of a selected obs. in the list of all observables + // fill in observable definition according to model at + // https://github.com/TheHive-Project/TheHiveDocs/blob/master/api/artifact.md + const obs = { + dataType: 'ip', + data: this.props.observables[j], + message: this.state.obsDescrs[j], + tlp: this.state.obsTLPs[j], + ioc: this.state.obsIOCs[j], + tags: this.state.obsTags[j], + }; + observables.push(obs); + } + console.log("Selected observables:", observables); + + // Submit request to create the case, handle response + createHiveCase(params.url, params.apikey, title, descr, severity, start_date, owner, flag, tlp, tags) + .then((resp) => { + if ('error' in resp) { + // Error contacting The Hive + console.error("TheHiveButton: ERROR when trying to create new case:", resp.error); + toastNotifications.addDanger("ERROR: " + resp.error); + return; + } + + console.log("TheHiveButton: Case created:", resp); + const case_id = resp.id; + const case_url = params.url + "index.html#/case/" + case_id + "/details"; + + // Show notification + let obs_text; + if (observables.length > 0) { + obs_text = "Adding " + observables.length + " observables in background ..."; + } + else { + obs_text = "(no observables added)"; + } + toastNotifications.add({ + title: "Case created", + color: "success", + iconType: "checkInCircleFilled", + text: ( + <div> + <p><b><a href={case_url} target="_blank">Edit the new Case</a></b></p> + <p>{obs_text}</p> + </div> + ), + }); + + // Open a new window with the case in The Hive + // (adding observables may take some time, so the case is opened first; + // The Hive web is dynamic so the observables appear as they are added) + window.open(case_url, '_blank'); + + if (observables.length == 0) + return; + + // Submit request to add observables + console.log("TheHiveButton: adding " + observables.length + " observables ..."); + addCaseObservables(params.url, params.apikey, case_id, observables) + .then((resp) => { + if ('error' in resp) { + console.error("TheHiveButton: ERROR when trying to add observables: " + resp.error); + toastNotifications.addDanger("ERROR when trying to add observables: " + resp.error); + } + else { + console.log("TheHiveButton: Done, observables added."); + toastNotifications.add("Done, observables added."); + } + }); // addObservables().then() func + }); // createHiveCase.then() func + + // Close the popup window + this.props.close(); + } } +// ********** Functions to send data to Kibana endpoints ********** + + // Create a new Case in The Hive via its API // Return a Promise which resolves to object with ID of the new case ('id' attr) or error message ('error' attr) function createHiveCase(base_url, api_key, title, descr, severity, startDate, owner, flag, tlp, tags) { @@ -149,7 +448,7 @@ function createHiveCase(base_url, api_key, title, descr, severity, startDate, ow "tags": tags, // array of strings } }); - console.log("Sending request to Kibana API endpoint of thehive_button plugin:", data); + console.log("TheHiveButton: Sending request to API endpoint 'new_case':", data); var kibana_endpoint_url = chrome.addBasePath('/api/thehive_button/new_case'); return new Promise(function (resolve, reject) { @@ -163,7 +462,7 @@ function createHiveCase(base_url, api_key, title, descr, severity, startDate, ow } if (this.status == 200) { const resp = JSON.parse(this.responseText); - console.log("Response from The Hive:", resp); + console.log("TheHiveButton: Response from backend:", resp); if ("error" in resp) { resolve({"error": resp.error}); } @@ -175,7 +474,7 @@ function createHiveCase(base_url, api_key, title, descr, severity, startDate, ow } } else { - console.log("Error " + this.status + ": " + this.statusText); + console.log("TheHiveButton: Error " + this.status + ": " + this.statusText); resolve({"error": "Error " + this.status + ": " + this.statusText}); } } @@ -188,25 +487,43 @@ function createHiveCase(base_url, api_key, title, descr, severity, startDate, ow }); } -class VisController { - constructor(el, vis) { - this.vis = vis; - this.el = el; - //console.log('constructor called!'); - this.container = document.createElement('div'); - this.container.className = 'myvis-container-div'; - this.button = createTheHiveButton(vis); - this.container.appendChild(this.button); - this.el.appendChild(this.container); - } - - destroy() { - this.el.innerHTML = ''; - } +// Add observables to an existing Case in The Hive +// (send the list of observables to our backend endpoint, it pushes them to The Hive) +function addCaseObservables(base_url, api_key, caseid, observables) { + const kibana_endpoint_url = chrome.addBasePath('/api/thehive_button/add_observables'); + const data = JSON.stringify({ + "base_url": base_url, + "api_key": api_key, + "caseid": caseid, + "observables": observables, + }); + console.log("TheHiveButton: Sending request to API endpoint 'add_observables':", data); + + return new Promise(function (resolve, reject) { + // Create AJAX request + var xhr = new XMLHttpRequest(); + + // Listener to process reply + xhr.onreadystatechange = function () { + if (this.readyState != 4) { + return; // response not ready yet + } + if (this.status == 200) { + const resp = JSON.parse(this.responseText); + console.log("TheHiveButton: Response from backend:", resp); + resolve(resp); + } + else { + console.log("TheHiveButton: Error " + this.status + ": " + this.statusText); + resolve({"error": "Error " + this.status + ": " + this.statusText}); + } + } - async render(visData, status) { - return 'done rendering'; - } -}; -export { VisController }; + // Send the AJAX request + xhr.open("POST", kibana_endpoint_url); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.setRequestHeader("kbn-xsrf", "thehive_plugin"); // this header must be set, although its content is probably irrelevant + xhr.send(data); + }); +} diff --git a/thehive_button/server/routes/newcase.js b/thehive_button/server/routes/newcase.js index 7400a5f..6f796ee 100644 --- a/thehive_button/server/routes/newcase.js +++ b/thehive_button/server/routes/newcase.js @@ -1,4 +1,8 @@ const request = require('request'); +//const fs = require('fs'); +//const path = require('path'); + +//const caFile = path.resolve(__dirname, '../../ca.cert.pem'); // TODO resolve where the CA file should be located / configured export default function (server) { server.route({ @@ -6,6 +10,11 @@ export default function (server) { method: 'POST', handler: newCaseHandler, }); + server.route({ + path: '/api/thehive_button/add_observables', + method: 'POST', + handler: addObservablesHandler, + }); } // Handler of ajax requests to create a new Case in The Hive @@ -25,6 +34,7 @@ function newCaseHandler(req, resp) { //if (!base_url.match(/https?:\/\/.*\//)) { return {'error': 'Invalid base URL (it must begin with "http[s]" and end with "/")'}; } + // TODO add "/" to the end automatically if (!api_key) { return {'error': 'API key not set'}; } @@ -36,18 +46,19 @@ function newCaseHandler(req, resp) { auth: {'bearer': api_key}, json: true, body: req_body, - //rejectUnauthorized: false, // Disables server certificate check + //ca: fs.readFileSync(caFile), // TODO resolve the issue with custom CA, where to get its cert? + rejectUnauthorized: false, }, // handler of the reply from The Hive - just return as reply function (error, response, body) { // TODO: find out how to set response code, for now we always return sucess and encode original status code in the content if (error) { - console.log("ERROR when trying to send request to The Hive:", error); + console.error("ERROR when trying to send request to The Hive:", error); resolve({'error': error.message}); } else { if (response.statusCode < 200 || response.statusCode >= 300) { - console.log("ERROR Unexpected reply received from The Hive:", response.statusCode, response.statusMessage, "\n", body) + console.error("ERROR Unexpected reply received from The Hive:", response.statusCode, response.statusMessage, "\n", body) } resolve({ 'status_code': response.statusCode, @@ -60,3 +71,84 @@ function newCaseHandler(req, resp) { }); // Promise() } +// Note: +// There are two ways to create multiple Observables (artifacts) via The Hive API: +// 1. post one request with an array of observables in "data" field +// - this allows to create all in one request, but doesn't allow to set +// different parameters (IOC, TLP, etc.) to different observables +// 2. post each observable in a separate request +// The second way is used here. + +// Handler of ajax requests to add Observables to a Case in The Hive +function addObservablesHandler(req, resp) { + // Parse the request to get connection parameters + // (everything is configured in forntend and sent as part of the request, + // since I don't know how to configure the backend) + var base_url = req.payload['base_url']; + var api_key = req.payload['api_key']; + + // check it's a valid URL with slash at the end + if (!base_url) { + return {'error': 'Base URL not set'}; + } + if (!base_url.match(/https?:\/\/(([a-z\d.-]+)|((\d{1,3}\.){3}\d{1,3}))(\:\d+)?(\/[-a-z\d%_.~+]*)*\//i)) { + //if (!base_url.match(/https?:\/\/.*\//)) { + return {'error': 'Invalid base URL (it must begin with "http[s]" and end with "/")'}; + } + // TODO add "/" to the end automatically + if (!api_key) { + return {'error': 'API key not set'}; + } + + const caseid = req.payload['caseid']; + const observables = req.payload['observables']; // array of obersvable specifications + + return new Promise( async function(resolve, reject) { + // Run one request for each observable + // (A way to run multiple async tasks sequentially inspired by: + // https://jrsinclair.com/articles/2019/how-to-run-async-js-in-parallel-or-sequential/ ) + const starterPromise = Promise.resolve(null); + await observables.reduce( + (p, obs) => p.then(() => addObservable(base_url, api_key, caseid, obs)), + starterPromise + ).catch((err_msg) => { + console.error(err_msg); // log whole message + resolve({'error': err_msg.split("\n", 1)[0]}); // send the first line to frontend + return; + } + ); + resolve({}); + }); +} + +function addObservable(base_url, api_key, caseid, obs) { + return new Promise( function(resolve, reject) { + console.log("Adding observable:", obs); + request({ + method: 'POST', + url: base_url + 'api/case/' + caseid + "/artifact", + auth: {'bearer': api_key}, + json: true, + body: obs, + //ca: fs.readFileSync(caFile), // TODO resolve the issue with custom CA, where to get its cert? + rejectUnauthorized: false, + }, + // handler of the reply from The Hive - just return as reply + function (error, response, body) { + if (error) { + reject("ERROR when trying to send request to The Hive: " + error); + } + else if (response.statusCode < 200 || response.statusCode >= 300) { + reject("ERROR: Unexpected reply received from The Hive: " + response.statusCode + " " + response.statusMessage + "\n" + JSON.stringify(body)); + } + else { + // success - continue with the next observable + resolve("OK"); + resolve({}) + } + } // handler function + ); // request() + }); //Promise() +} + + -- GitLab