Add custom fields to TheHive (#1985)

* Add custom fields support to TheHive node

*  Improvements to #1527

* 🐛 Make it also work without custom fields set

Co-authored-by: Mika Luhta <12100880+mluhta@users.noreply.github.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Ricardo Espinoza 2021-07-14 13:18:46 -04:00 committed by GitHub
parent c5a1bc007f
commit c983603306
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 520 additions and 12 deletions

View file

@ -13,6 +13,7 @@ import {
} from 'n8n-workflow';
import * as moment from 'moment';
import { Eq } from './QueryFunctions';
export async function theHiveApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const credentials = this.getCredentials('theHiveApi');
@ -77,7 +78,10 @@ export function prepareOptional(optionals: IDataObject): IDataObject {
const response: IDataObject = {};
for (const key in optionals) {
if (optionals[key] !== undefined && optionals[key] !== null && optionals[key] !== '') {
if (moment(optionals[key] as string, moment.ISO_8601).isValid()) {
if (['customFieldsJson', 'customFieldsUi'].indexOf(key) > -1) {
continue; // Ignore customFields, they need special treatment
}
else if (moment(optionals[key] as string, moment.ISO_8601).isValid()) {
response[key] = Date.parse(optionals[key] as string);
} else if (key === 'artifacts') {
response[key] = JSON.parse(optionals[key] as string);
@ -91,6 +95,73 @@ export function prepareOptional(optionals: IDataObject): IDataObject {
return response;
}
export async function prepareCustomFields(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, additionalFields: IDataObject, jsonParameters = false): Promise<IDataObject | undefined> {
// Check if the additionalFields object contains customFields
if (jsonParameters === true) {
const customFieldsJson = additionalFields.customFieldsJson;
// Delete from additionalFields as some operations (e.g. alert:update) do not run prepareOptional
// which would remove the extra fields
delete additionalFields.customFieldsJson;
if (typeof customFieldsJson === 'string') {
return JSON.parse(customFieldsJson);
} else if (typeof customFieldsJson === 'object') {
return customFieldsJson as IDataObject;
} else if (customFieldsJson) {
throw Error('customFieldsJson value is invalid');
}
} else if (additionalFields.customFieldsUi) {
// Get Custom Field Types from TheHive
const version = this.getCredentials('theHiveApi')?.apiVersion;
const endpoint = version === 'v1' ? '/customField' : '/list/custom_fields';
const requestResult = await theHiveApiRequest.call(
this,
'GET',
endpoint as string,
);
// Convert TheHive3 response to the same format as TheHive 4
// [{name, reference, type}]
const hiveCustomFields = version === 'v1' ? requestResult : Object.keys(requestResult).map(key => requestResult[key]);
// Build reference to type mapping object
const referenceTypeMapping = hiveCustomFields.reduce((acc: IDataObject, curr: IDataObject) => (acc[curr.reference as string] = curr.type, acc), {});
// Build "fieldName": {"type": "value"} objects
const customFieldsUi = (additionalFields.customFieldsUi as IDataObject);
const customFields : IDataObject = (customFieldsUi?.customFields as IDataObject[]).reduce((acc: IDataObject, curr: IDataObject) => {
const fieldName = curr.field as string;
// Might be able to do some type conversions here if needed, TODO
acc[fieldName] = {
[referenceTypeMapping[fieldName]]: curr.value,
};
return acc;
}, {} as IDataObject);
delete additionalFields.customFieldsUi;
return customFields;
}
return undefined;
}
export function buildCustomFieldSearch(customFields: IDataObject): IDataObject[] {
const customFieldTypes = ['boolean', 'date', 'float', 'integer', 'number', 'string'];
const searchQueries: IDataObject[] = [];
Object.keys(customFields).forEach(customFieldName => {
const customField = customFields[customFieldName] as IDataObject;
// Figure out the field type from the object's keys
const fieldType = Object.keys(customField)
.filter(key => customFieldTypes.indexOf(key) > -1)[0];
const fieldValue = customField[fieldType];
searchQueries.push(Eq(`customFields.${customFieldName}.${fieldType}`, fieldValue));
});
return searchQueries;
}
export function prepareSortQuery(sort: string, body: { query: [IDataObject] }) {
if (sort) {
const field = sort.substring(1);

View file

@ -56,7 +56,9 @@ import {
} from './QueryFunctions';
import {
buildCustomFieldSearch,
mapResource,
prepareCustomFields,
prepareOptional,
prepareRangeQuery,
prepareSortQuery,
@ -180,6 +182,31 @@ export class TheHive implements INodeType {
}
return returnData;
},
async loadCustomFields(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const version = this.getCredentials('theHiveApi')?.apiVersion;
const endpoint = version === 'v1' ? '/customField' : '/list/custom_fields';
const requestResult = await theHiveApiRequest.call(
this,
'GET',
endpoint as string,
);
const returnData: INodePropertyOptions[] = [];
// Convert TheHive3 response to the same format as TheHive 4
const customFields = version === 'v1' ? requestResult : Object.keys(requestResult).map(key => requestResult[key]);
for (const field of customFields) {
returnData.push({
name: `${field.name}: ${field.reference}`,
value: field.reference,
description: `${field.type}: ${field.description}`,
} as INodePropertyOptions);
}
return returnData;
},
async loadObservableOptions(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
// if v1 is not used we remove 'count' option
const version = this.getCredentials('theHiveApi')?.apiVersion;
@ -296,10 +323,17 @@ export class TheHive implements INodeType {
for (let i = 0; i < length; i++) {
if (resource === 'alert') {
if (operation === 'count') {
const countQueryAttributs: any = prepareOptional(this.getNodeParameter('filters', i, {}) as INodeParameters); // tslint:disable-line:no-any
const filters = this.getNodeParameter('filters', i, {}) as INodeParameters;
const countQueryAttributs: any = prepareOptional(filters); // tslint:disable-line:no-any
const _countSearchQuery: IQueryObject = And();
if ('customFieldsUi' in filters) {
const customFields = await prepareCustomFields.call(this, filters) as IDataObject;
const searchQueries = buildCustomFieldSearch(customFields);
(_countSearchQuery['_and'] as IQueryObject[]).push(...searchQueries);
}
for (const key of Object.keys(countQueryAttributs)) {
if (key === 'tags') {
(_countSearchQuery['_and'] as IQueryObject[]).push(
@ -348,6 +382,10 @@ export class TheHive implements INodeType {
}
if (operation === 'create') {
const additionalFields = this.getNodeParameter('additionalFields', i) as INodeParameters;
const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean;
const customFields = await prepareCustomFields.call(this, additionalFields, jsonParameters);
const body: IDataObject = {
title: this.getNodeParameter('title', i),
description: this.getNodeParameter('description', i),
@ -360,7 +398,8 @@ export class TheHive implements INodeType {
source: this.getNodeParameter('source', i),
sourceRef: this.getNodeParameter('sourceRef', i),
follow: this.getNodeParameter('follow', i, true),
...prepareOptional(this.getNodeParameter('optionals', i, {}) as INodeParameters),
customFields,
...prepareOptional(additionalFields),
};
const artifactUi = this.getNodeParameter('artifactUi', i) as IDataObject;
@ -497,12 +536,18 @@ export class TheHive implements INodeType {
const version = credentials.apiVersion;
const queryAttributs: any = prepareOptional(this.getNodeParameter('filters', i, {}) as INodeParameters); // tslint:disable-line:no-any
const filters = this.getNodeParameter('filters', i, {}) as INodeParameters;
const queryAttributs: any = prepareOptional(filters); // tslint:disable-line:no-any
const options = this.getNodeParameter('options', i) as IDataObject;
const _searchQuery: IQueryObject = And();
if ('customFieldsUi' in filters) {
const customFields = await prepareCustomFields.call(this, filters) as IDataObject;
const searchQueries = buildCustomFieldSearch(customFields);
(_searchQuery['_and'] as IQueryObject[]).push(...searchQueries);
}
for (const key of Object.keys(queryAttributs)) {
if (key === 'tags') {
(_searchQuery['_and'] as IQueryObject[]).push(
@ -634,14 +679,18 @@ export class TheHive implements INodeType {
if (operation === 'update') {
const alertId = this.getNodeParameter('id', i) as string;
const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean;
const updateFields = this.getNodeParameter('updateFields', i) as IDataObject;
const customFields = await prepareCustomFields.call(this, updateFields, jsonParameters);
const artifactUi = updateFields.artifactUi as IDataObject;
delete updateFields.artifactUi;
const body: IDataObject = {};
const body: IDataObject = {
customFields,
};
Object.assign(body, updateFields);
@ -1149,10 +1198,17 @@ export class TheHive implements INodeType {
if (resource === 'case') {
if (operation === 'count') {
const countQueryAttributs: any = prepareOptional(this.getNodeParameter('filters', i, {}) as INodeParameters); // tslint:disable-line:no-any
const filters = this.getNodeParameter('filters', i, {}) as INodeParameters;
const countQueryAttributs: any = prepareOptional(filters); // tslint:disable-line:no-any
const _countSearchQuery: IQueryObject = And();
if ('customFieldsUi' in filters) {
const customFields = await prepareCustomFields.call(this, filters) as IDataObject;
const searchQueries = buildCustomFieldSearch(customFields);
(_countSearchQuery['_and'] as IQueryObject[]).push(...searchQueries);
}
for (const key of Object.keys(countQueryAttributs)) {
if (key === 'tags') {
(_countSearchQuery['_and'] as IQueryObject[]).push(
@ -1258,6 +1314,9 @@ export class TheHive implements INodeType {
}
if (operation === 'create') {
const options = this.getNodeParameter('options', i, {}) as INodeParameters;
const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean;
const customFields = await prepareCustomFields.call(this, options, jsonParameters);
const body: IDataObject = {
title: this.getNodeParameter('title', i),
@ -1268,7 +1327,8 @@ export class TheHive implements INodeType {
flag: this.getNodeParameter('flag', i),
tlp: this.getNodeParameter('tlp', i),
tags: splitTags(this.getNodeParameter('tags', i) as string),
...prepareOptional(this.getNodeParameter('options', i, {}) as INodeParameters),
customFields,
...prepareOptional(options),
};
responseData = await theHiveApiRequest.call(
@ -1333,12 +1393,19 @@ export class TheHive implements INodeType {
const version = credentials.apiVersion;
const queryAttributs: any = prepareOptional(this.getNodeParameter('filters', i, {}) as INodeParameters); // tslint:disable-line:no-any
const filters = this.getNodeParameter('filters', i, {}) as INodeParameters;
const queryAttributs: any = prepareOptional(filters); // tslint:disable-line:no-any
const _searchQuery: IQueryObject = And();
const options = this.getNodeParameter('options', i) as IDataObject;
if ('customFieldsUi' in filters) {
const customFields = await prepareCustomFields.call(this, filters) as IDataObject;
const searchQueries = buildCustomFieldSearch(customFields);
(_searchQuery['_and'] as IQueryObject[]).push(...searchQueries);
}
for (const key of Object.keys(queryAttributs)) {
if (key === 'tags') {
(_searchQuery['_and'] as IQueryObject[]).push(
@ -1419,9 +1486,14 @@ export class TheHive implements INodeType {
if (operation === 'update') {
const id = this.getNodeParameter('id', i) as string;
const updateFields = this.getNodeParameter('updateFields', i, {}) as INodeParameters;
const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean;
const customFields = await prepareCustomFields.call(this, updateFields, jsonParameters);
const body: IDataObject = {
...prepareOptional(this.getNodeParameter('updateFields', i, {}) as INodeParameters),
customFields,
...prepareOptional(updateFields),
};
responseData = await theHiveApiRequest.call(

View file

@ -468,6 +468,24 @@ export const alertFields = [
},
},
},
{
displayName: 'JSON Parameters',
name: 'jsonParameters',
type: 'boolean',
default: true,
displayOptions: {
show: {
resource: [
'alert',
],
operation: [
'create',
'update',
],
},
},
},
// optional attributs (Create, Promote operations)
{
displayName: 'Additional Fields',
@ -483,6 +501,89 @@ export const alertFields = [
],
operation: [
'create',
],
},
},
options: [
{
displayName: 'Case Template',
name: 'caseTemplate',
type: 'string',
default: '',
description: `Case template to use when a case is created from this alert.`,
},
{
displayName: 'Custom Fields',
name: 'customFieldsUi',
type: 'fixedCollection',
default: {},
displayOptions: {
show: {
'/jsonParameters': [
false,
],
},
},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Custom Field',
options: [
{
name: 'customFields',
displayName: 'Custom Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
typeOptions: {
loadOptionsMethod: 'loadCustomFields',
},
default: 'Custom Field',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Custom Field value. Use an expression if the type is not a string.',
},
],
},
],
},
{
displayName: 'Custom Fields (JSON)',
name: 'customFieldsJson',
type: 'string',
default: '',
displayOptions: {
show: {
'/jsonParameters': [
true,
],
},
},
description: 'Custom fields in JSON format. Overrides Custom Fields UI if set.',
},
],
},
// optional attributs (Promote operation)
{
displayName: 'Additional Fields',
name: 'additionalFields',
placeholder: 'Add Field',
type: 'collection',
required: false,
default: '',
displayOptions: {
show: {
resource: [
'alert',
],
operation: [
'promote',
],
},
@ -581,6 +682,61 @@ export const alertFields = [
},
],
},
{
displayName: 'Custom Fields',
name: 'customFieldsUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
'/jsonParameters': [
false,
],
},
},
placeholder: 'Add Custom Field',
options: [
{
name: 'customFields',
displayName: 'Custom Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
typeOptions: {
loadOptionsMethod: 'loadCustomFields',
},
default: 'Custom Field',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Custom Field value. Use an expression if the type is not a string.',
},
],
},
],
},
{
displayName: 'Custom Fields (JSON)',
name: 'customFieldsJson',
type: 'string',
displayOptions: {
show: {
'/jsonParameters': [
true,
],
},
},
default: '',
description: 'Custom fields in JSON format. Overrides Custom Fields UI if set.',
},
{
displayName: 'Case Template',
name: 'caseTemplate',
@ -737,6 +893,40 @@ export const alertFields = [
},
},
options: [
{
displayName: 'Custom Fields',
name: 'customFieldsUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Custom Field',
options: [
{
name: 'customFields',
displayName: 'Custom Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
typeOptions: {
loadOptionsMethod: 'loadCustomFields',
},
default: 'Custom Field',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Custom Field value. Use an expression if the type is not a string.',
},
],
},
],
},
{
displayName: 'Description',
name: 'description',

View file

@ -295,6 +295,23 @@ export const caseFields = [
},
},
},
{
displayName: 'JSON Parameters',
name: 'jsonParameters',
type: 'boolean',
default: true,
displayOptions: {
show: {
resource: [
'case',
],
operation: [
'create',
'update',
],
},
},
},
// Optional fields (Create operation)
{
displayName: 'Options',
@ -314,6 +331,61 @@ export const caseFields = [
required: false,
default: '',
options: [
{
displayName: 'Custom Fields',
name: 'customFieldsUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
'/jsonParameters': [
false,
],
},
},
placeholder: 'Add Custom Field',
options: [
{
name: 'customFields',
displayName: 'Custom Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
typeOptions: {
loadOptionsMethod: 'loadCustomFields',
},
default: 'Custom Field',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Custom Field value. Use an expression if the type is not a string.',
},
],
},
],
},
{
displayName: 'Custom Fields (JSON)',
name: 'customFieldsJson',
type: 'string',
default: '',
displayOptions: {
show: {
'/jsonParameters': [
true,
],
},
},
description: 'Custom fields in JSON format. Overrides Custom Fields UI if set.',
},
{
displayName: 'End Date',
name: 'endDate',
@ -333,6 +405,13 @@ export const caseFields = [
name: 'metrics',
default: '[]',
type: 'json',
displayOptions: {
show: {
'/jsonParameters': [
true,
],
},
},
description: 'List of metrics',
},
],
@ -356,6 +435,61 @@ export const caseFields = [
required: false,
default: '',
options: [
{
displayName: 'Custom Fields',
name: 'customFieldsUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
'/jsonParameters': [
false,
],
},
},
placeholder: 'Add Custom Field',
options: [
{
name: 'customFields',
displayName: 'Custom Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
typeOptions: {
loadOptionsMethod: 'loadCustomFields',
},
default: 'Custom Field',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Custom Field value. Use an expression if the type is not a string.',
},
],
},
],
},
{
displayName: 'Custom Fields (JSON)',
name: 'customFieldsJson',
type: 'string',
default: '',
displayOptions: {
show: {
'/jsonParameters': [
true,
],
},
},
description: 'Custom fields in JSON format. Overrides Custom Fields UI if set.',
},
{
displayName: 'Description',
name: 'description',
@ -403,6 +537,13 @@ export const caseFields = [
name: 'metrics',
type: 'json',
default: '[]',
displayOptions: {
show: {
'/jsonParameters': [
true,
],
},
},
description: 'List of metrics',
},
{
@ -583,6 +724,40 @@ export const caseFields = [
},
},
options: [
{
displayName: 'Custom Fields',
name: 'customFieldsUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Custom Field',
options: [
{
name: 'customFields',
displayName: 'Custom Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
typeOptions: {
loadOptionsMethod: 'loadCustomFields',
},
default: 'Custom Field',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Custom Field value. Use an expression if the type is not a string.',
},
],
},
],
},
{
displayName: 'Description',
name: 'description',