-
Václav Bartoš authored
Needs some more testing, but everything seems to work well.
Václav Bartoš authoredNeeds some more testing, but everything seems to work well.
vis_controller.js 19.20 KiB
//import { Status } from 'ui/vis/update_status';
import { toastNotifications } from 'ui/notify';
import { createTheHiveCase, addCaseObservables } from './create_case';
//import vis_template from './vis_template.html';
import React, { Component } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiForm,
EuiFormRow,
EuiFieldText,
EuiTextArea,
EuiSuperSelect,
EuiBasicTable,
EuiCheckbox,
makeId,
} from '@elastic/eui';
// ********** React components **********
// Main React component - the root of visualization
export class TheHiveButtonVisComponent extends Component {
render() {
//console.log("TheHiveButtonVisComponent.render(), props:", this.props);
return (
<div>
<NewCaseButton params={this.props.vis.params} observables={this.props.visData} />
</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 - object with lists of potential observables to add to the Case
// for each field in params.obsFields there should be a key in this object
// containing list of observables (this is returned by request_handler)
class NewCaseButton extends Component {
constructor(props) {
super(props);
// Filter out invalid obsField specifications
this.obsFields = props.params.obsFields.filter( f => (f.name != "" && f.type != "" && f.cnt > 0) );
//console.log("Filtered field specs:", this.obsFields);
// The complete state is here, so it's kept even when modal is closed
this.state = {
isModalVisible: false,
isWorking: false, // used to show a spinner on submit button
...this.create_initial_state(),
}
this.resetCnt = 0; // used to change Modal component key on each form reset
// 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.resetForm = this.resetForm.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.onObsSelectionChange = this.onObsSelectionChange.bind(this);
this.onObsDataChange = this.onObsDataChange.bind(this);
this.submitCase = this.submitCase.bind(this);
}
create_initial_state() {
// create a new instance of initial state definition
let initial_state = {
// Case parameters
title: "",
description: "\n\n--\nCreated from Kibana",
severity: "2", // medium
tlp: "2", // amber
tags: [], // TODO (not implemented yet)
obsData: {}, // state of observables form fields (obsData->field->index->{descr,tlp,ioc,tags})
obsSel: {}, // list of observable selections (obsSel->field->list_of_selected_indices)
}
// pre-fill state of each observable to defaults
const initial_field_data = {descr: "", tlp: 2, ioc: false, tags: []};
for (let field of this.obsFields) {
const n_obs = this.props.observables[field.name].length;
// fill obsData with new copies of initial_field_data
initial_state.obsData[field.name] = new Array(n_obs).fill().map((_)=>({...initial_field_data}));
// nothing is selected
initial_state.obsSel[field.name] = new Array();
}
return initial_state;
}
componentDidUpdate(prevProps) {
// If list of observables was updated or obsFields setting has changed,
// reset the component state and precomputed variables.
if (this.props.observables != prevProps.observables) {
if (this.props.params.obsFields != prevProps.params.obsFields) {
// when obsFields change, observables must change as well, so this "if"
// can be inside the first one.
// Filter out invalid obsField specifications
this.obsFields = this.props.params.obsFields.filter( f => (f.name != "" && f.type != "" && f.cnt && f.cnt > 0) );
//console.log("Filtered field specs:", this.obsFields);
}
//console.log("New list of observables, resetting form.");
this.resetForm();
}
}
resetForm() {
this.setState(this.create_initial_state());
this.resetCnt += 1; // this changes the key of ModalContent, causing it to be replaced by new DOM elelments (otherwise, not all things are reset properly)
this.forceUpdate();
}
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 handler for observable (de)selection
onObsSelectionChange(fieldName, selectedItems) {
// Extract indices from the items and store them into state
const selectedIndices = selectedItems.map(item4 => item4.i);
this.setState((state, props) => {
let newObsSel = {...this.state.obsSel};
newObsSel[fieldName] = selectedIndices;
return {obsSel: newObsSel};
});
}
// Event handler for edit of a form field in observable row
// - fieldName: which field (table of observables)
// - ix: index of the observable in the field's table
// - param: one of: descr,tlp,ioc,tags
// - value: new value of the form field
onObsDataChange(fieldName, ix, param, value) {
this.setState((state, props) => {
let newObsData = {...this.state.obsData};
newObsData[fieldName][ix][param] = value;
return {obsData: newObsData};
});
}
// Render function
render() {
let modal;
if (this.state.isModalVisible) {
modal = <ModalContent
resetCnt={this.resetCnt} // used to change "key" of modalBody, causing all form fields to be re-created (some things are not reset properly by reseting state only)
close={this.closeModal}
reset={this.resetForm}
fields={this.obsFields}
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}
obsData={this.state.obsData}
obsSel={this.state.obsSel}
spinner={this.state.isWorking}
// event handlers
onTitleChange={this.onTitleChange}
onSeverityChange={this.onSeverityChange}
onTLPChange={this.onTLPChange}
onDescriptionChange={this.onDescriptionChange}
onObsSelectionChange={this.onObsSelectionChange}
onObsDataChange={this.onObsDataChange}
submitCase={this.submitCase}
/>;
}
return (
<div>
<EuiButton fill iconType="alert" color="danger" onClick={this.showModal}>Create new Case ...</EuiButton>
{modal}
</div>
);
}
// Submit case button handler
async 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 = [];
for (let field of this.obsFields) {
let selectionIndices = [...this.state.obsSel[field.name]]; // 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: field.type,
data: this.props.observables[field.name][j],
message: this.state.obsData[field.name][j].descr,
tlp: this.state.obsData[field.name][j].tlp,
ioc: this.state.obsData[field.name][j].ioc,
tags: this.state.obsData[field.name][j].tags,
};
observables.push(obs);
}
}
//console.log("Selected observables:", observables);
// Check '/' at the end of base URL, add it if needed
let base_url = params.url;
if (base_url[base_url.length-1] != "/") {
base_url += "/";
}
// Show spinner at submit button
this.setState({isWorking: true});
// Submit request to create the case, handle response
let resp;
resp = await createTheHiveCase(base_url, params.apikey, title, descr, severity, start_date, owner, flag, tlp, tags);
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);
this.setState({isWorking: false}); // Hide spinner
return;
}
console.log("TheHiveButton: Case created:", resp);
const case_id = resp.id;
const case_url = base_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>
),
});
// Close the popup window, reset form fields and hide spinner
this.closeModal();
this.resetForm();
this.setState({isWorking: false});
// 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 ...");
resp = await addCaseObservables(base_url, params.apikey, case_id, observables);
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.");
}
}
}
// The popup window with a form
// props:
// - spinner: when true, disable form and show a spinner over it
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"},
];
}
// Main render function
render() {
// TODO: replace Modal with Flyout?
// Note: onClick on EuiOverlayMask causes close of modal when clicked outside,
// implementation inspired by PR: https://github.com/elastic/eui/pull/3462/files#diff-c8fda532e48f75c94c343247cbc6b2d3R53-R60
return (
<EuiOverlayMask onClick={(evt) => {if (evt.target.classList.contains("euiOverlayMask")) this.props.close();} }>
<EuiModal onClose={this.props.close} maxWidth={false} initialFocus="[name=title]">
<EuiModalHeader>
<EuiModalHeaderTitle>Create a new case in The Hive</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody key={this.props.resetCnt}>
<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>
{this.props.fields.length > 0 && <EuiTitle size="s"><h3>Add observables from current query ...</h3></EuiTitle>}
{this.props.fields.map((field,ix) => (
<ObservablesTable
key={field.name + ":" + this.props.resetCnt}
fieldName={field.name}
observables={this.props.observables[field.name]}
obsData={this.props.obsData[field.name]}
obsSel={this.props.obsSel[field.name]}
onObsSelectionChange={this.props.onObsSelectionChange}
onObsDataChange={this.props.onObsDataChange}
/>
))}
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={this.props.close}>Close</EuiButtonEmpty>
<EuiButtonEmpty onClick={this.props.reset}>Reset</EuiButtonEmpty>
<EuiButton onClick={this.props.submitCase} fill isLoading={this.props.spinner}>Create Case</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
}
}
// Table of potential observables taken from a given field, allowing to select
// which observables to send to The Hive.
// Props:
// fieldName - name of the field this table is for
// observables - list of observable IDs of this field
// obsData - array of objects specifying state of form fields in the table (.descr, .tlp, ...)
// obsSel - array of indices of selected observables
class ObservablesTable extends Component {
constructor(props) {
super(props);
// 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, item1) => (<EuiFieldText
value={item1.descr}
onChange={(e) => this.props.onObsDataChange(props.fieldName, item1.i, "descr", e.target.value)}
disabled={!item1.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, item2) => (<EuiCheckbox
id={"ioc-checkbox-"+item2.id}
checked={item2.ioc}
onChange={(e) => this.props.onObsDataChange(props.fieldName, item2.i, "ioc", e.target.checked)}
disabled={!item2.selected}
/>)
},
/*{
field: "tags",
name: "Tags",
// TODO render and process changes
},*/
]
// Create a reference to EuiBasicTable, so it's node can be accessed in componentDidMount
this.tableRef = React.createRef();
}
render() {
// Table data definition (convert props to format suitable for EuiBasicTable)
const n_obs = this.props.observables.length;
this.table_data = new Array(n_obs);
for (let i = 0; i < n_obs; i++) {
this.table_data[i] = {
id: this.props.observables[i],
descr: this.props.obsData[i].descr,
tlp: this.props.obsData[i].tlp,
ioc: this.props.obsData[i].ioc,
tags: this.props.obsData[i].tags,
// auxiliary fields, not shown in table:
i: i, // row index
selected: this.props.obsSel.includes(i),
};
}
return (
<>
<EuiTitle size="xs"><h4>{this.props.fieldName}</h4></EuiTitle>
<EuiBasicTable
ref={this.tableRef}
columns={this.columns}
items={this.table_data}
itemId={(item3) => item3.id}
selection={ {onSelectionChange: (selectedItems) => this.props.onObsSelectionChange(this.props.fieldName, selectedItems) } }
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
}}
/>
<EuiSpacer size="l" />
</>
)
}
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 is created.
// Prepare the 'selection' array - it should contain a list of selected row specifications
let selection = [];
for (let ix of this.props.obsSel) {
selection.push(this.table_data[ix]);
}
// Get ref to EuiBasicTable element and update its state
const table_node = this.tableRef.current;
table_node.setState({selection: selection});
}
}