Skip to content
Snippets Groups Projects
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});
  }
}