[Security Solution][Detection Engine] fixes lists/items API when @timestamp field is number (#210440)

## Summary

- addresses https://github.com/elastic/security-team/issues/11831

**To Reproduce**

1. Create Security lists/items in 7.17 by uploading value list
https://www.elastic.co/guide/en/security/current/value-lists-exceptions.html
2. Upgrade to 8.18
3. Visit detection engine page to ensure .lists-{SPACE} and
.items-{SPACE} data streams have been created. Would be enough to lookup
value lists in lists UI
https://www.elastic.co/guide/en/security/current/value-lists-exceptions.html#edit-value-lists
4. Go to Kibana Upgrade assistant
5. Reindex .lists-{SPACE} and .items-{SPACE}  data streams
6. After reindex lists are not retrievable with error
`"data.0.@timestamp: Expected string, received number"
` through `/lists/_find` API

**After fix**

`@timestamp` of number type will be converted to ISO string

**To test**

use 8.18 mirror of this branch:
https://github.com/elastic/kibana/pull/210439

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Vitalii Dmyterko 2025-02-14 20:09:53 +00:00 committed by GitHub
parent 262969d15d
commit fc5adc02fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 104 additions and 9 deletions

View file

@ -10,3 +10,8 @@ import { IsoDateString } from '@kbn/securitysolution-io-ts-types';
export const timestamp = IsoDateString;
export const timestampOrUndefined = t.union([IsoDateString, t.undefined]);
/**
* timestamp field type as it can be returned form ES: string, number or undefined
*/
export const timestampFromEsResponse = t.union([IsoDateString, t.number, t.undefined]);

View file

@ -14,7 +14,7 @@ import {
nullableMetaOrUndefined,
serializerOrUndefined,
tie_breaker_id,
timestampOrUndefined,
timestampFromEsResponse,
updated_at,
updated_by,
} from '@kbn/securitysolution-io-ts-list-types';
@ -47,7 +47,7 @@ import {
export const searchEsListItemSchema = t.exact(
t.type({
'@timestamp': timestampOrUndefined,
'@timestamp': timestampFromEsResponse,
binary: binaryOrUndefined,
boolean: booleanOrUndefined,
byte: byteOrUndefined,

View file

@ -41,7 +41,9 @@ export const getSearchEsListMock = (): SearchEsListSchema => ({
version: VERSION,
});
export const getSearchListMock = (): estypes.SearchResponse<SearchEsListSchema> => ({
export const getSearchListMock = (
source?: SearchEsListSchema
): estypes.SearchResponse<SearchEsListSchema> => ({
_scroll_id: '123',
_shards: getShardMock(),
hits: {
@ -50,7 +52,7 @@ export const getSearchListMock = (): estypes.SearchResponse<SearchEsListSchema>
_id: LIST_ID,
_index: LIST_INDEX,
_score: 0,
_source: getSearchEsListMock(),
_source: source || getSearchEsListMock(),
},
],
max_score: 0,

View file

@ -16,7 +16,7 @@ import {
nullableMetaOrUndefined,
serializerOrUndefined,
tie_breaker_id,
timestampOrUndefined,
timestampFromEsResponse,
type,
updated_at,
updated_by,
@ -25,7 +25,7 @@ import { version } from '@kbn/securitysolution-io-ts-types';
export const searchEsListSchema = t.exact(
t.type({
'@timestamp': timestampOrUndefined,
'@timestamp': timestampFromEsResponse,
created_at,
created_by,
description,

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/**
* if date field is number, converts it to ISO string
*/
export const convertDateNumberToString = (
dateValue: string | number | undefined
): string | undefined => {
if (typeof dateValue === 'number') {
return new Date(dateValue).toISOString();
}
return dateValue;
};

View file

@ -79,7 +79,7 @@ export const deserializeValue = ({
deserializer: DeserializerOrUndefined;
defaultValueDeserializer: string;
defaultDeserializer: string;
value: string | object | undefined;
value: string | object | number | undefined;
}): string | null => {
if (esDataTypeRange.is(value)) {
const template =

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
getSearchEsListMock,
getSearchListMock,
} from '../../schemas/elastic_response/search_es_list_schema.mock';
import { transformElasticToList } from './transform_elastic_to_list';
describe('transformElasticToList', () => {
test('does not change timestamp in string format', () => {
const response = getSearchListMock({
...getSearchEsListMock(),
'@timestamp': '2020-04-20T15:25:31.830Z',
});
const result = transformElasticToList({
response,
});
expect(result[0]['@timestamp']).toBe('2020-04-20T15:25:31.830Z');
});
test('converts timestamp from number format to ISO string', () => {
const response = getSearchListMock({ ...getSearchEsListMock(), '@timestamp': 0 });
const result = transformElasticToList({
response,
});
expect(result[0]['@timestamp']).toBe('1970-01-01T00:00:00.000Z');
});
});

View file

@ -11,6 +11,8 @@ import { encodeHitVersion } from '@kbn/securitysolution-es-utils';
import { SearchEsListSchema } from '../../schemas/elastic_response';
import { convertDateNumberToString } from './convert_date_number_to_string';
export interface TransformElasticToListOptions {
response: estypes.SearchResponse<SearchEsListSchema>;
}
@ -24,6 +26,7 @@ export const transformElasticToList = ({
_version: encodeHitVersion(hit),
id: hit._id,
...hit._source,
'@timestamp': convertDateNumberToString(hit._source?.['@timestamp']),
// meta can be null if deleted (empty in PUT payload), since update_by_query set deleted values as null
// return it as undefined to keep it consistent with payload
meta: hit._source?.meta ?? undefined,

View file

@ -8,7 +8,10 @@
import type { ListItemArraySchema } from '@kbn/securitysolution-io-ts-list-types';
import { getListItemResponseMock } from '../../../common/schemas/response/list_item_schema.mock';
import { getSearchListItemMock } from '../../schemas/elastic_response/search_es_list_item_schema.mock';
import {
getSearchEsListItemMock,
getSearchListItemMock,
} from '../../schemas/elastic_response/search_es_list_item_schema.mock';
import {
transformElasticHitsToListItem,
@ -84,5 +87,32 @@ describe('transform_elastic_to_list_item', () => {
const expected: ListItemArraySchema = [listItemResponse];
expect(queryFilter).toEqual(expected);
});
test('converts timestamp from number format to ISO string', () => {
const hits = [{ _index: 'test', _source: { ...getSearchEsListItemMock(), '@timestamp': 0 } }];
const result = transformElasticHitsToListItem({
hits,
type: 'keyword',
});
expect(result[0]['@timestamp']).toBe('1970-01-01T00:00:00.000Z');
});
test('converts negative from number timestamp to ISO string', () => {
const hits = [
{
_index: 'test',
_source: { ...getSearchEsListItemMock(), '@timestamp': -63549289600000 },
},
];
const result = transformElasticHitsToListItem({
hits,
type: 'keyword',
});
expect(result[0]['@timestamp']).toBe('-000044-03-15T19:33:20.000Z');
});
});
});

View file

@ -13,6 +13,7 @@ import { ErrorWithStatusCode } from '../../error_with_status_code';
import { SearchEsListItemSchema } from '../../schemas/elastic_response';
import { findSourceValue } from './find_source_value';
import { convertDateNumberToString } from './convert_date_number_to_string';
export interface TransformElasticToListItemOptions {
response: estypes.SearchResponse<SearchEsListItemSchema>;
@ -56,7 +57,7 @@ export const transformElasticHitsToListItem = ({
throw new ErrorWithStatusCode(`Was expected ${type} to not be null/undefined`, 400);
} else {
return {
'@timestamp': _source?.['@timestamp'],
'@timestamp': convertDateNumberToString(_source?.['@timestamp']),
_version: encodeHitVersion(hit),
created_at,
created_by,