Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const generateMockIndicator = (): Indicator => {
indicator.fields['threat.indicator.type'] = ['type'];
indicator.fields['threat.indicator.ip'] = ['0.0.0.0'];
indicator.fields['threat.indicator.name'] = ['0.0.0.0'];
indicator.fields['threat.indicator.name_origin'] = ['forwarded', 'abusech-malware'];

return indicator;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { IndicatorFieldValue } from './field_value';
import {
RawIndicatorFieldId,
generateMockIndicator,
generateMockIndicatorWithTlp,
} from '../../../../../common/types/indicator';
Expand All @@ -27,7 +28,13 @@ describe('<IndicatorField />', () => {
);
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
0.0.0.0
<div
class="css-12og07k-IndicatorFieldValue"
>
<span>
0.0.0.0
</span>
</div>
</DocumentFragment>
`);
});
Expand Down Expand Up @@ -84,4 +91,27 @@ describe('<IndicatorField />', () => {
</DocumentFragment>
`);
});

it('should render multiple values', () => {
const mockField = RawIndicatorFieldId.NameOrigin;

const { asFragment } = render(
<IndicatorFieldValue indicator={mockIndicator} field={mockField} />
);

expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div
class="css-12og07k-IndicatorFieldValue"
>
<span>
forwarded
</span>
<span>
abusech-malware
</span>
</div>
</DocumentFragment>
`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
* 2.0.
*/

import React, { VFC } from 'react';
import React from 'react';
import type { FC } from 'react';
import { css } from '@emotion/react';
import { isArray } from 'lodash';
import { useFieldTypes } from '../../../../hooks/use_field_types';
import { EMPTY_VALUE } from '../../../../constants/common';
import { Indicator, RawIndicatorFieldId } from '../../../../../common/types/indicator';
Expand All @@ -25,22 +28,41 @@ export interface IndicatorFieldValueProps {
}

/**
* Takes an indicator object, a field and a field => type object to returns the correct value to display.
* @returns If the type is a 'date', returns the {@link DateFormatter} component, else returns the value or {@link EMPTY_VALUE}.
* Renders an indicator field value based on its type:
* - TLP fields → `<TLPBadge />`
* - Date fields → `<DateFormatter />`
* - Missing value → {@link EMPTY_VALUE}
* - String or string[] → renders each value in a vertical column
*/
export const IndicatorFieldValue: VFC<IndicatorFieldValueProps> = ({ indicator, field }) => {

export const IndicatorFieldValue: FC<IndicatorFieldValueProps> = ({ indicator, field }) => {
const fieldType = useFieldTypes()[field];
const value = unwrapValue(indicator, field as RawIndicatorFieldId);

if (field === RawIndicatorFieldId.MarkingTLP) {
if (field === RawIndicatorFieldId.MarkingTLP && !isArray(value)) {
return <TLPBadge value={value} />;
}

return fieldType === 'date' ? (
<DateFormatter date={value as string} />
) : value ? (
<>{value}</>
) : (
<>{EMPTY_VALUE}</>
if (fieldType === 'date') {
return <DateFormatter date={value as string} />;
}

if (!value) {
return EMPTY_VALUE;
}

const values = [value].flat();

return (
<div
css={css`
display: flex;
flex-direction: column;
`}
>
{values.map((val, idx) => (
<span key={`${value}-${idx}`}>{val}</span>
))}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import React, { useMemo, VFC } from 'react';
import { Indicator } from '../../../../../common/types/indicator';
import { IndicatorFieldValue } from '../common/field_value';
import { IndicatorValueActions } from './indicator_value_actions';
// import { unwrapValue } from '../../../utils/unwrap_value.ts'
import { unwrapValue } from '../../utils/unwrap_value';

interface TableItem {
key: string;
value: string | string[] | null;
}

export interface IndicatorFieldsTableProps {
fields: string[];
Expand Down Expand Up @@ -64,10 +71,22 @@ export const IndicatorFieldsTable: VFC<IndicatorFieldsTableProps> = ({
[indicator, dataTestSubj]
);

const items = useMemo(() => {
return fields.toSorted().reduce<TableItem[]>((acc, field) => {
const value = unwrapValue(indicator, field);
return [
...acc,
{
key: field,
value,
},
];
}, []);
}, [fields, indicator]);

return (
<EuiInMemoryTable
// @ts-expect-error - EuiInMemoryTable wants an array of objects, but will accept strings if coerced
items={fields.sort()}
items={items}
// @ts-expect-error - EuiInMemoryTable wants an array of objects, but will accept strings if coerced
columns={columns}
sorting={true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { IndicatorFieldsTable } from './fields_table';
/**
* Pick indicator fields starting with the indicator type
*/
const byIndicatorType = (indicatorType: string) => (field: string) =>
const byIndicatorType = (indicatorType: string, field: string) =>
field.startsWith(`threat.indicator.${indicatorType}`) ||
[
'threat.indicator.reference',
Expand All @@ -36,12 +36,13 @@ export const HighlightedValuesTable: VFC<HighlightedValuesTableProps> = ({
indicator,
'data-test-subj': dataTestSubj,
}) => {
const indicatorType = unwrapValue(indicator, RawIndicatorFieldId.Type);

const highlightedFields: string[] = useMemo(
() => Object.keys(indicator.fields).filter(byIndicatorType(indicatorType || '')),
[indicator.fields, indicatorType]
);
const highlightedFields = useMemo(() => {
const indicatorType = unwrapValue(indicator, RawIndicatorFieldId.Type);
const sanitisedIndicatorType = (!Array.isArray(indicatorType) && indicatorType) || '';
return Object.keys(indicator.fields).filter((field) =>
byIndicatorType(sanitisedIndicatorType, field)
);
}, [indicator]);

return (
<IndicatorFieldsTable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { EMPTY_VALUE } from '../../../constants/common';
import { unwrapValue } from './unwrap_value';
import { Indicator, RawIndicatorFieldId } from '../../../../common/types/indicator';

const normalize = (v: string | string[] | null): string | null => {
if (v == null) return null;
if (Array.isArray(v)) return v.length > 0 ? v[0] : null;
return v;
};

/**
* Retrieves a field/value pair from an Indicator
* @param data the {@link Indicator} to extract the value for the field
Expand All @@ -19,16 +25,18 @@ export const getIndicatorFieldAndValue = (
data: Indicator,
field: string
): { key: string; value: string | null } => {
const value = unwrapValue(data, field as RawIndicatorFieldId);
const key =
field === RawIndicatorFieldId.Name
? (unwrapValue(data, RawIndicatorFieldId.NameOrigin) as string)
: field;
const rawValue = unwrapValue(data, field as RawIndicatorFieldId);
const value = normalize(rawValue);

let key = field;
if (field === RawIndicatorFieldId.Name) {
const nameOrigin = normalize(unwrapValue(data, RawIndicatorFieldId.NameOrigin));
if (nameOrigin) {
key = nameOrigin;
}
}

return {
key,
value,
};
return { key, value };
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,32 @@ type IndicatorLike = Record<'fields', Record<string, unknown>> | null | undefine
* Unpacks field value from raw indicator fields. Will return null if fields are missing entirely
* or there is no record for given `fieldId`
*/
export const unwrapValue = <T = string>(indicator: IndicatorLike, fieldId: string): T | null => {
export const unwrapValue = (
indicator: IndicatorLike,
fieldId: string
): string | string[] | null => {
if (!indicator) {
return null;
}

const fieldValues = indicator.fields?.[fieldId];
const raw = indicator.fields?.[fieldId];

if (!Array.isArray(fieldValues)) {
return null;
// Handle string directly
if (typeof raw === 'string') {
return raw;
}

const firstValue = fieldValues[0];
// Handle arrays by collecting only strings
if (Array.isArray(raw)) {
const strings = raw.filter((v): v is string => typeof v === 'string');

if (strings.length === 0) return null;
if (strings.length === 1) return strings[0];

// Multiple string values -> return array so callers can OR them
return strings;
}

return typeof firstValue === 'object' ? null : (firstValue as T);
// Any other type -> unsupported
return null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import { Filter } from '@kbn/es-query';
export const FilterIn = true;
export const FilterOut = false;

type Value = string | string[] | null;

const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0;

/**
* Creates a new filter to apply to the KQL bar.
* @param key A string value mainly representing the field of an indicator
* @param value A string value mainly representing the value of the indicator for the field
* @param negate Set to true when we create a negated filter (e.g. NOT threat.indicator.type: url)
* @returns The new {@link Filter}
* Create a single phrase (match_phrase) filter for a field/value.
* @param key Field name
* @param value Exact value to match
* @param negate Whether the filter is negated
* @param index Optional data view id
* @returns A Kibana {@link Filter} with meta.type 'phrase'
*/
const createFilter = ({
key,
Expand Down Expand Up @@ -54,46 +59,57 @@ const filterExistsInFiltersArray = (
): Filter | undefined =>
filters.find(
(f: Filter) =>
f.meta.key === key &&
f.meta?.type === 'phrase' &&
f.meta?.key === key &&
typeof f.meta.params === 'object' &&
'query' in f.meta.params &&
f.meta.params?.query === value
);

/**
* Returns true if the filter exists and should be removed, false otherwise (depending on a FilterIn or FilterOut action)
* @param filter The {@link Filter}
* @param filterType The type of action ({@link FilterIn} or {@link FilterOut})
* Should an existing matching filter be replaced to flip its negation?
* @param existing Existing matching filter
* @param filterType {@link FilterIn} or {@link FilterOut}
* @returns true to replace (toggle negation), false to add another filter
*/
const shouldRemoveFilter = (filter: Filter | undefined, filterType: boolean): boolean =>
filter != null && filterType === filter.meta.negate;
const shouldReplaceNegation = (existing: Filter | undefined, filterType: boolean): boolean =>
!!existing && filterType === existing.meta.negate;

/**
* Takes an array of filters and returns the updated array according to:
* - if a filter already exists but negated, replace it by it's negation
* - add the newly created filter
* @param existingFilters List of {@link Filter} retrieved from the filterManager
* @param key The value used in the newly created [@link Filter} as a key
* @param value The value used in the newly created [@link Filter} as a params query
* @param filterType Weather the function is called for a {@link FilterIn} or {@link FilterOut} action
* @returns the updated array of filters
* Update filters:
* - Normalizes values to unique strings
* - For a single value: add or replace a phrase filter
* - For multiple values: applies the same logic for each value, adding one phrase filter per value
*
* @param existingFilters Current filters from filterManager
* @param key Field name
* @param value String or string[] (multi-value supported)
* @param filterType {@link FilterIn} (include) or {@link FilterOut} (exclude)
* @param index Optional data view id
* @returns Updated filters array
*/
export const updateFiltersArray = (
existingFilters: Filter[],
key: string,
value: string | null,
value: Value,
filterType: boolean,
index?: string
): Filter[] => {
const newFilter = createFilter({ key, value: value as string, negate: !filterType, index });
// Normalize to unique non-empty strings
const nonEmptyValues = [value].flat().filter(isNonEmptyString);
const sanitizedValues = Array.from(new Set(nonEmptyValues));

const filter: Filter | undefined = filterExistsInFiltersArray(
existingFilters,
key,
value as string
);
if (sanitizedValues.length === 0) return existingFilters;

return sanitizedValues.reduce<Filter[]>((result, v) => {
const existing = filterExistsInFiltersArray(result, key, v);
const newFilter = createFilter({ key, value: v, negate: !filterType, index });

if (shouldReplaceNegation(existing, filterType)) {
// Flip negation by replacing the opposite one
return [...result.filter((f) => f !== existing), newFilter];
}

return shouldRemoveFilter(filter, filterType)
? [...existingFilters.filter((f: Filter) => f !== filter), newFilter]
: [...existingFilters, newFilter];
return [...result, newFilter];
}, existingFilters);
};