diff --git a/group_vars/all/main.yml b/group_vars/all/main.yml index 521b079b6b93cdb120d0966c44700e1284cd13f4..84d605b7475c546285a802b78307b05764f975f6 100644 --- a/group_vars/all/main.yml +++ b/group_vars/all/main.yml @@ -2,6 +2,11 @@ dslproxy: "<CHANGE_ME:hostname>" +# TheHive Button plugin +THEHIVE_URL: "https://hive.gn4-3-wp8-soc.sunet.se/" +THEHIVE_API_KEY: "5LymseWiurZBrQN8Kqp8O+9KniTL5cE0" +THEHIVE_OWNER: "admin" + soctools_netname: "soctoolsnet" repo: gn43-dsl diff --git a/roles/build/tasks/odfekibana.yml b/roles/build/tasks/odfekibana.yml index 8ca53578baceacba76726bece7c1dfbe7491c917..8e1980a7680f5f23b7370d61dba057e457430291 100644 --- a/roles/build/tasks/odfekibana.yml +++ b/roles/build/tasks/odfekibana.yml @@ -5,6 +5,9 @@ src: odfekibana/Dockerfile-kibana.j2 dest: "{{role_path}}/files/kibanaDockerfile" +- name: Copy tools to build path + command: "cp -av {{role_path}}/templates/odfekibana/thehive_button/ {{role_path}}/files/thehive_button/" + - name: Build kibana image command: docker build -t {{repo}}/kibana:{{version}}{{suffix}} -f {{role_path}}/files/kibanaDockerfile {{role_path}}/files diff --git a/roles/build/templates/odfekibana/Dockerfile-odfekibana.j2 b/roles/build/templates/odfekibana/Dockerfile-odfekibana.j2 index 8f72fd770ba85b5b54962fe96dd9bb5bbd898069..ee69568d34d6fe879de100414f8f3b96b2252d7f 100644 --- a/roles/build/templates/odfekibana/Dockerfile-odfekibana.j2 +++ b/roles/build/templates/odfekibana/Dockerfile-odfekibana.j2 @@ -11,5 +11,8 @@ RUN for PLUGIN in \ https://d3g5vo6xdbdb9a.cloudfront.net/downloads/kibana-plugins/opendistro-index-management/opendistro_index_management_kibana-{{odfeplugin_version}}.zip; \ do bin/kibana-plugin install --allow-root ${PLUGIN}; done +ADD thehive_button /usr/share/kibana/plugins/thehive_button +RUN chown -R kibana:kibana /usr/share/kibana/plugins/thehive_button + USER kibana diff --git a/roles/build/templates/odfekibana/thehive_button/.eslintrc b/roles/build/templates/odfekibana/thehive_button/.eslintrc new file mode 100644 index 0000000000000000000000000000000000000000..64eba86220ec489c9c364e9a443941d14a8d3b16 --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/.eslintrc @@ -0,0 +1,7 @@ +--- +extends: "@elastic/kibana" + +settings: + import/resolver: + '@elastic/eslint-import-resolver-kibana': + rootPackageName: 'thehive_button' diff --git a/roles/build/templates/odfekibana/thehive_button/.kibana-plugin-helpers.json b/roles/build/templates/odfekibana/thehive_button/.kibana-plugin-helpers.json new file mode 100644 index 0000000000000000000000000000000000000000..2c63c0851048d8f7bff41ecf0f8cee05f52fd120 --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/.kibana-plugin-helpers.json @@ -0,0 +1,2 @@ +{ +} diff --git a/roles/build/templates/odfekibana/thehive_button/index.js b/roles/build/templates/odfekibana/thehive_button/index.js new file mode 100644 index 0000000000000000000000000000000000000000..fa69c75c30d7ee40f8d7089d6debd6cf69c8d402 --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/index.js @@ -0,0 +1,19 @@ +import newCaseRoute from './server/routes/newcase'; + +export default function (kibana) { + return new kibana.Plugin({ + require: [], //['elasticsearch'], + name: 'thehive_button', + uiExports: { + visTypes: [ + 'plugins/thehive_button/main', + ], + }, + + init(server, options) { // eslint-disable-line no-unused-vars + // Add server routes and initialize the plugin here + newCaseRoute(server); + } + }); +} + diff --git a/roles/build/templates/odfekibana/thehive_button/package.json b/roles/build/templates/odfekibana/thehive_button/package.json new file mode 100644 index 0000000000000000000000000000000000000000..4290a7869d461c81f8793593dd2282141903c75d --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/package.json @@ -0,0 +1,35 @@ +{ + "name": "thehive_button", + "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.4.2" + }, + "scripts": { + "lint": "eslint .", + "start": "plugin-helpers start", + "build": "plugin-helpers build" + }, + "dependencies": { + "request": "^2.88.0", + "@elastic/eui": "10.4.2", + "react": "^16.8.0" + }, + "devDependencies": { + "@elastic/eslint-config-kibana": "link:../../packages/eslint-config-kibana", + "@elastic/eslint-import-resolver-kibana": "link:../../packages/kbn-eslint-import-resolver-kibana", + "@kbn/plugin-helpers": "link:../../packages/kbn-plugin-helpers", + "babel-eslint": "^9.0.0", + "eslint": "^5.6.0", + "eslint-plugin-babel": "^5.2.0", + "eslint-plugin-import": "^2.14.0", + "eslint-plugin-jest": "^21.26.2", + "eslint-plugin-jsx-a11y": "^6.1.2", + "eslint-plugin-mocha": "^5.2.0", + "eslint-plugin-no-unsanitized": "^3.0.2", + "eslint-plugin-prefer-object-spread": "^1.2.1", + "eslint-plugin-react": "^7.11.1", + "expect.js": "^0.3.1" + } +} diff --git a/roles/build/templates/odfekibana/thehive_button/public/main.js b/roles/build/templates/odfekibana/thehive_button/public/main.js new file mode 100644 index 0000000000000000000000000000000000000000..363156967e0684554abdd0580280a9e772a62c93 --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/public/main.js @@ -0,0 +1,128 @@ +//import './vis.less'; +import { THEHIVE_API_KEY, THEHIVE_URL, THEHIVE_OWNER } from './env'; +//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'; +import { DefaultEditorSize } from 'ui/vis/editor_size'; + +import optionsTemplate from './options_template.html'; + +/* +TODO: It would be better to compose options tab of EUI React elements, +but this probably needs at least kibana 7.4. +See this for example of a custom editor: + https://github.com/elastic/kibana/tree/master/src/plugins/vis_type_markdown/public + +import React from 'react'; +import { + EuiForm, + EuiFormRow, + EuiFieldText, +} from '@elastic/eui'; + +import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; + +function optionsEditor({ stateParams, setValue }) { + return ( + <EuiForm> + <EuiFormRow label="Base URL of The Hive"> + <EuiFieldText value={stateParams.url} /> + </EuiFormRow> + <EuiFormRow label="API key"> + <EuiFieldText value={stateParams.apikey} /> + </EuiFormRow> + <EuiFormRow label="Username" helpText="Used as the owner of cases created from here"> + <EuiFieldText value={stateParams.owner} /> + </EuiFormRow> + </EuiForm> +); +*/ + +function TheHiveButtonVisProvider(Private) { + const VisFactory = Private(VisFactoryProvider); + + console.log("default URL:", THEHIVE_URL); + console.log("default API key:", THEHIVE_API_KEY); + + return VisFactory.createReactVisualization({ + name: 'thehive_button', + title: 'The Hive Case', + icon: 'alert', + description: 'A button to create a new Case in The Hive.', + //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: optionsEditor, + editor: 'default', + editorConfig: { + optionsTemplate: optionsTemplate, + defaultSize: DefaultEditorSize.MEDIUM, + //enableAutoApply: true, + 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, ignore this)", + min: 1, + max: 1, + aggFilter: 'count', + defaults: [ + { + type: 'count', + schema: 'metric', + }, + ], + editor: '<div class="hintbox"><i class="fa fa-danger text-info"></i> Some metric must be defined here, but it\'s setting is irrelevant. Just go to "Buckets" below and set up the field to get Observbles from.</div>' + }, + { + group: 'buckets', + name: 'group', + title: 'Observables', + min: 0, + //max: 1, + aggFilter: ['terms'], + defaults: [ + { + type: 'terms', + schema: 'group', + field: 'ip', + size: 1000, + orderBy: 'alphabetical', + order: 'ascending', + } + ] + }, + ]), + }, + //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 + }); +} + +// register the provider with the visTypes registry +VisTypesRegistryProvider.register(TheHiveButtonVisProvider); + diff --git a/roles/build/templates/odfekibana/thehive_button/public/options_template.html b/roles/build/templates/odfekibana/thehive_button/public/options_template.html new file mode 100644 index 0000000000000000000000000000000000000000..ef996577786150282c2ffb0d28652a3d1712842b --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/public/options_template.html @@ -0,0 +1,8 @@ +<div class="form-group"> + <p><label>Base URL of The Hive</label> + <input ng-model="editorState.params.url" class=form-control /></p> + <p><label>API key</label> + <input ng-model="editorState.params.apikey" class=form-control /></p> + <p><label>User name to use as the owner of cases created from here</label> + <input ng-model="editorState.params.owner" class=form-control /></p> +</div> diff --git a/roles/build/templates/odfekibana/thehive_button/public/vis.less b/roles/build/templates/odfekibana/thehive_button/public/vis.less new file mode 100644 index 0000000000000000000000000000000000000000..b6f887afaef57a7674a0d0f06ee6f821a0fc015e --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/public/vis.less @@ -0,0 +1,3 @@ +.myvis-container-div { + padding: 1em; +} diff --git a/roles/build/templates/odfekibana/thehive_button/public/vis_controller.js b/roles/build/templates/odfekibana/thehive_button/public/vis_controller.js new file mode 100644 index 0000000000000000000000000000000000000000..3018390b86d22dc8f9fa0a62c2164d98963a1047 --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/public/vis_controller.js @@ -0,0 +1,609 @@ +//import { Status } from 'ui/vis/update_status'; +import chrome from 'ui/chrome'; +import { toastNotifications } from 'ui/notify'; +//import vis_template from './vis_template.html'; + +import React, { Component } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiTextArea, + EuiSuperSelect, + EuiBasicTable, + EuiCheckbox, + makeId, +} from '@elastic/eui'; + +// TODO: +// - Explicit reset button +// - Reset when data (query) changes +// - Hide modal when clicked outside + + +// ********** 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); + const n_obs = props.observables.length; + + // initial state of form fields - used to reset form + this.initial_case_state = { + // Case parameters + title: "", + description: "\n\n--\nCreated from Kibana", + severity: "2", // medium + tlp: "2", // amber + tags: [], // TODO (not implemented yet) + // 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), // TODO (not implemented yet) + obsIOCs: new Array(n_obs).fill(false), + obsTags: new Array(n_obs).fill([]), // TODO (not implemented yet) + obsSelected: new Array(), // list of indices of selected observables + } + + // The complete state is here, so it's kept even when modal is closed + this.state = { + isModalVisible: false, + ...this.initial_case_state, // TODO isn't deep copy needed here (and in reset_form below)? + } + + // Each handler function in a class (method) must be "binded" this way + this.closeModal = this.closeModal.bind(this); + this.showModal = this.showModal.bind(this); + + this.onTitleChange = this.onTitleChange.bind(this); + this.onSeverityChange = this.onSeverityChange.bind(this); + this.onTLPChange = this.onTLPChange.bind(this); + this.onDescriptionChange = this.onDescriptionChange.bind(this); + + this.onObsDescrChange = this.onObsDescrChange.bind(this); + this.onObsTLPChange = this.onObsTLPChange.bind(this); + this.onObsIOCChange = this.onObsIOCChange.bind(this); + this.onObsTagsChange = this.onObsTagsChange.bind(this); + this.onObsSelectionChange = this.onObsSelectionChange.bind(this); + + this.submitCase = this.submitCase.bind(this); + } + + resetForm() { + this.setState(this.initial_case_state); + } + + closeModal() { + this.setState({ isModalVisible: false }); + } + + showModal() { + this.setState({ isModalVisible: true }); + } + + // Event handlers for change of case parameter + onTitleChange(evt) { + this.setState({title: evt.target.value}); + } + onSeverityChange(value) { + this.setState({severity: value}); + } + onTLPChange(value) { + this.setState({tlp: value}); + } + onDescriptionChange(evt) { + this.setState({description: evt.target.value}); + } + + // Event handlers for change of observable parameters + onObsDescrChange(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}; + }); + } + onObsTLPChange(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}; + }); + } + onObsIOCChange(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}; + }); + } + onObsTagsChange(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}; + }); + } + + // Event handler for row (de)selection + onObsSelectionChange(selectedItems) { + // Extract indices from the items and store them into state + const selectedIndices = selectedItems.map(item => item.i); + this.setState({obsSelected: selectedIndices}); + } + + // Submit case button handler + 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); + + // All data cached in local variables - reset the form fields + this.resetForm(); + + // Submit request to create the case, handle response + // TODO rewite to await + 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.closeModal(); + } + + // Render function + render() { + let modal; + if (this.state.isModalVisible) { + modal = <ModalContent + close={this.closeModal} + observables={this.props.observables} + // form state + title={this.state.title} + description={this.state.description} + severity={this.state.severity} + tlp={this.state.tlp} + tags={this.state.tags} + obsDescrs={this.state.obsDescrs} + obsTLPs={this.state.obsTLPs} + obsIOCs={this.state.obsIOCs} + obsTags={this.state.obsTags} + obsSelected={this.state.obsSelected} + // event handlers + onTitleChange={this.onTitleChange} + onSeverityChange={this.onSeverityChange} + onTLPChange={this.onTLPChange} + onDescriptionChange={this.onDescriptionChange} + onObsDescrChange={this.onObsDescrChange} + onObsTLPChange={this.onObsTLPChange} + onObsIOCChange={this.onObsIOCChange} + onObsTagsChange={this.onObsTagsChange} + onObsSelectionChange={this.onObsSelectionChange} + submitCase={this.submitCase} + />; + } + return ( + <div> + <EuiButton fill iconType="alert" color="danger" onClick={this.showModal}>Create new Case ...</EuiButton> + {modal} + </div> + ); + } +} + + +// The popup window with a form +class ModalContent extends Component { + constructor(props) { + super(props); + // No state here, everything is in the parent class (NewCaseButton) + + // "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 columns definition + this.columns = [ + { + field: "id", + name: "Observable", + }, + { + 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.props.onObsDescrChange(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.props.onObsIOCChange(e, item.i)} + disabled={!item.selected} + />) + }, + /*{ + field: "tags", + name: "Tags", + // TODO render and process changes + },*/ + ] + + // Create a reference to observables table, so it's node can be accessed in componentDidMount + this.obsTableRef = React.createRef(); + } + + // Main render function + 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.props.obsDescrs[i], + tlp: this.props.obsTLPs[i], + ioc: this.props.obsIOCs[i], + tags: this.props.obsTags[i], + // auxiliary fields, not shown in table: + i: i, // row index + selected: this.props.obsSelected.includes(i), + }; + } + this.table_data = table_data; // needed in componentDidMount + + return ( + <EuiOverlayMask> + <EuiModal onClose={this.props.close} maxWidth={false} initialFocus="[name=title]"> + <EuiModalHeader> + <EuiModalHeaderTitle>Create a new case in The Hive</EuiModalHeaderTitle> + </EuiModalHeader> + + <EuiModalBody> + <EuiForm style={{width: "800px"}}> + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <EuiFormRow label="Title" fullWidth> + <EuiFieldText name="title" value={this.props.title} onChange={this.props.onTitleChange} required={true} fullWidth /> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFormRow label="Severity"> + <EuiSuperSelect + options={this.severityOptions} + valueOfSelected={this.props.severity} + onChange={this.props.onSeverityChange} + /> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiFormRow label="TLP"> + <EuiSuperSelect + prepend="TLP" + options={this.tlpOptions} + valueOfSelected={this.props.tlp} + onChange={this.props.onTLPChange} + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFormRow label="Description" fullWidth> + <EuiTextArea + defaultValue={this.props.description} + onChange={this.props.onDescriptionChange} + rows={4} + fullWidth + /> + </EuiFormRow> + + <EuiTitle size="s"><h3>Add observables from current query ...</h3></EuiTitle> + <EuiBasicTable + ref={this.obsTableRef} + columns={this.columns} + items={table_data} + itemId={(item) => item.id} + selection={ {onSelectionChange: this.props.onObsSelectionChange} } + 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.props.submitCase} fill>Create Case</EuiButton> + </EuiModalFooter> + </EuiModal> + </EuiOverlayMask> + ); + } + + componentDidMount() { + // There's no way to specify initially selected items in EuiBasicTable by + // props, but we may need to select some (in case a user selects some obs., + // closes the modal and opens it again). + // However, the selection is stored as a 'selection' field of table's state, + // so here we directly edit the state just after the table crated. + + // Prepare the 'selection' array - it should contain a list of selected row specifications + let selection = []; + for (let i = 0; i < this.props.obsSelected.length; i++) { + selection.push(this.table_data[this.props.obsSelected[i]]); + } + + // Get ref to EuiBasicTable element and update its state + const table_node = this.obsTableRef.current; + table_node.setState({selection: selection}); + } +} + + +// ********** 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) { + // Prepare data + // TODO: check '/' at the end of base URL + var data = JSON.stringify({ + "base_url": base_url, + "api_key": api_key, + "body": { + "title": title, + "description": descr, + "severity": severity, // number: 1=low, 2=medium, 3=high + "startDate": startDate, + "owner": owner, // user name the case will be assigned to + "flag": flag, // bool + "tlp": tlp, // number: 0=white, 1=green, 2=amber, 3=red + "tags": tags, // array of strings + } + }); + 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) { + // 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); + if ("error" in resp) { + resolve({"error": resp.error}); + } + else if (resp.status_code != 201) { + resolve({"error": "Unexpected reply received from The Hive: [" + resp.status_code + "] " + resp.status_msg}); + } + else { + resolve({"id": resp.body.id}); // return ID of the new case + } + } + else { + console.log("TheHiveButton: Error " + this.status + ": " + this.statusText); + resolve({"error": "Error " + this.status + ": " + this.statusText}); + } + } + + // 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); + }); +} + +// 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}); + } + } + + // 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/roles/build/templates/odfekibana/thehive_button/server/routes/newcase.js b/roles/build/templates/odfekibana/thehive_button/server/routes/newcase.js new file mode 100644 index 0000000000000000000000000000000000000000..6f796ee82295b6e892a240ebf2eabe30ad207362 --- /dev/null +++ b/roles/build/templates/odfekibana/thehive_button/server/routes/newcase.js @@ -0,0 +1,154 @@ +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({ + path: '/api/thehive_button/new_case', + 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 +function newCaseHandler(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']; + var req_body = req.payload['body']; + + // 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'}; + } + + return new Promise( function(resolve, reject) { + request({ + method: 'POST', + url: base_url + 'api/case', + auth: {'bearer': api_key}, + json: true, + body: req_body, + //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.error("ERROR when trying to send request to The Hive:", error); + resolve({'error': error.message}); + } + else { + if (response.statusCode < 200 || response.statusCode >= 300) { + console.error("ERROR Unexpected reply received from The Hive:", response.statusCode, response.statusMessage, "\n", body) + } + resolve({ + 'status_code': response.statusCode, + 'status_msg': response.statusMessage, + 'body': body + }); + } + } // handler function + ); // request() + }); // 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() +} + + diff --git a/roles/odfekibana/files/env.js.j2 b/roles/odfekibana/files/env.js.j2 new file mode 100644 index 0000000000000000000000000000000000000000..a075a2781bbbed7d80132a22a7544c7235041018 --- /dev/null +++ b/roles/odfekibana/files/env.js.j2 @@ -0,0 +1,4 @@ +// Default plugin configuration +export const THEHIVE_URL = '{{THEHIVE_URL}}'; +export const THEHIVE_API_KEY = '{{THEHIVE_API_KEY}}'; +export const THEHIVE_OWNER = '{{THEHIVE_OWNER}}'; // default owner account of the created cases diff --git a/roles/odfekibana/tasks/main.yml b/roles/odfekibana/tasks/main.yml index 04426116397d59bd5d241ac133e1c46e3e767c96..77f2c37af6ab25b659fe5c0fc29c8e82820a040b 100644 --- a/roles/odfekibana/tasks/main.yml +++ b/roles/odfekibana/tasks/main.yml @@ -85,6 +85,17 @@ # tags: # - start + +- name: Generate configuration for thehive_button plugin + template: + src: files/env.js.j2 + dest: "/usr/share/kibana/plugins/thehive_button/public/env.js" + owner: kibana + group: kibana + tags: + - start + + - name: Start OpenDistro Kibana for Elasticsearch command: /usr/share/kibana/startkibana.sh #shell: exec /usr/share/kibana/bin/kibana -c config/kibana.yml &