diff --git a/thehive_button/public/vis_controller.js b/thehive_button/public/vis_controller.js index 2ddeb1fb70341ad31761e11a79e26ba170cdff01..3018390b86d22dc8f9fa0a62c2164d98963a1047 100644 --- a/thehive_button/public/vis_controller.js +++ b/thehive_button/public/vis_controller.js @@ -26,6 +26,11 @@ import { makeId, } from '@elastic/eui'; +// TODO: +// - Explicit reset button +// - Reset when data (query) changes +// - Hide modal when clicked outside + // ********** React components ********** @@ -63,12 +68,50 @@ 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() { @@ -79,10 +122,199 @@ class NewCaseButton extends Component { 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} params={this.props.params} observables={this.props.observables} />; + 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> @@ -93,32 +325,12 @@ class NewCaseButton extends Component { } } + // 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 - }; + // No state here, everything is in the parent class (NewCaseButton) // "Select" options this.severityOptions = [ @@ -133,7 +345,7 @@ class ModalContent extends Component { {value: "3", inputDisplay: "red"}, ]; - // Table column definition + // Table columns definition this.columns = [ { field: "id", @@ -145,7 +357,7 @@ class ModalContent extends Component { 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)} + onChange={(e) => this.props.onObsDescrChange(e, item.i)} disabled={!item.selected} />) }, @@ -163,7 +375,7 @@ class ModalContent extends Component { render: (value, item) => (<EuiCheckbox id={"ioc-checkbox-"+item.id} checked={item.ioc} - onChange={(e) => this.onChangeIOC(e, item.i)} + onChange={(e) => this.props.onObsIOCChange(e, item.i)} disabled={!item.selected} />) }, @@ -174,63 +386,11 @@ class ModalContent extends Component { },*/ ] - // 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); - } - - // 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}; - }); - } - - // 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}); + // Create a reference to observables table, so it's node can be accessed in componentDidMount + this.obsTableRef = React.createRef(); } // 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 @@ -238,16 +398,16 @@ class ModalContent extends Component { 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], + 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.state.obsSelected.includes(i), + selected: this.props.obsSelected.includes(i), }; } - //console.log("render(): Table data:", table_data); + this.table_data = table_data; // needed in componentDidMount return ( <EuiOverlayMask> @@ -261,15 +421,15 @@ class ModalContent extends Component { <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 /> + <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.state.severity} - onChange={(val) => this.setState({severity: val})} + valueOfSelected={this.props.severity} + onChange={this.props.onSeverityChange} /> </EuiFormRow> </EuiFlexItem> @@ -278,16 +438,16 @@ class ModalContent extends Component { <EuiSuperSelect prepend="TLP" options={this.tlpOptions} - valueOfSelected={this.state.tlp} - onChange={(val) => this.setState({tlp: val})} + valueOfSelected={this.props.tlp} + onChange={this.props.onTLPChange} /> </EuiFormRow> </EuiFlexItem> </EuiFlexGroup> <EuiFormRow label="Description" fullWidth> <EuiTextArea - defaultValue={this.state.description} - onChange={(e) => this.setState({description: e.target.value})} + defaultValue={this.props.description} + onChange={this.props.onDescriptionChange} rows={4} fullWidth /> @@ -295,10 +455,11 @@ class ModalContent extends Component { <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.onSelectionChange} } + selection={ {onSelectionChange: this.props.onObsSelectionChange} } noItemsMessage="No observables found" rowProps={{ // Hack to allow selection by clicking anywhere in the table row @@ -318,110 +479,29 @@ class ModalContent extends Component { <EuiModalFooter> <EuiButtonEmpty onClick={this.props.close}>Cancel</EuiButtonEmpty> - <EuiButton onClick={this.submitCase} fill>Create Case</EuiButton> + <EuiButton onClick={this.props.submitCase} fill>Create Case</EuiButton> </EuiModalFooter> </EuiModal> </EuiOverlayMask> ); } - 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; - } + 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. - // 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); + // 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]]); } - 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(); + // Get ref to EuiBasicTable element and update its state + const table_node = this.obsTableRef.current; + table_node.setState({selection: selection}); } }