-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Integrate Asset Inventory with backend #208417
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,27 +4,20 @@ | |
* 2.0; you may not use this file except in compliance with the Elastic License | ||
* 2.0. | ||
*/ | ||
import React, { useMemo, useCallback } from 'react'; | ||
import React, { useMemo } from 'react'; | ||
import { EuiSpacer, EuiFlexItem } from '@elastic/eui'; | ||
import { i18n } from '@kbn/i18n'; | ||
import { FilterGroup } from '@kbn/alerts-ui-shared/src/alert_filter_controls/filter_group'; | ||
import type { FilterControlConfig } from '@kbn/alerts-ui-shared'; | ||
import type { Filter } from '@kbn/es-query'; | ||
import { createKbnUrlStateStorage, Storage } from '@kbn/kibana-utils-plugin/public'; | ||
import { Storage } from '@kbn/kibana-utils-plugin/public'; | ||
import { ControlGroupRenderer } from '@kbn/controls-plugin/public'; | ||
import { useHistory } from 'react-router-dom'; | ||
import { useDataViewContext } from '../../hooks/data_view_context'; | ||
import { useSpaceId } from '../../../common/hooks/use_space_id'; | ||
import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state'; | ||
import { useGlobalTime } from '../../../common/containers/use_global_time'; | ||
import { ASSET_INVENTORY_INDEX_PATTERN } from '../../constants'; | ||
import { FilterGroupLoading } from './filters_loading'; | ||
import { ASSET_INVENTORY_RULE_TYPE_IDS } from './rule_type_ids'; | ||
|
||
const SECURITY_ASSET_INVENTORY_DATA_VIEW = { | ||
id: 'asset-inventory-logs-default', | ||
name: 'asset-inventory-logs', | ||
}; | ||
|
||
const DEFAULT_ASSET_INVENTORY_FILTERS: FilterControlConfig[] = [ | ||
{ | ||
title: i18n.translate('xpack.securitySolution.assetInventory.filters.type', { | ||
|
@@ -57,64 +50,22 @@ export interface FiltersProps { | |
} | ||
|
||
export const Filters = ({ onFiltersChange }: FiltersProps) => { | ||
const { dataView: indexPattern, dataViewIsLoading, dataViewIsRefetching } = useDataViewContext(); | ||
const { from, to } = useGlobalTime(); | ||
const { dataView, dataViewIsLoading, dataViewIsRefetching } = useDataViewContext(); | ||
const spaceId = useSpaceId(); | ||
const history = useHistory(); | ||
const urlStorage = useMemo( | ||
() => | ||
createKbnUrlStateStorage({ | ||
history, | ||
useHash: false, | ||
useHashQuery: false, | ||
}), | ||
[history] | ||
); | ||
const filterControlsUrlState = useMemo( | ||
() => | ||
urlStorage.get<FilterControlConfig[] | undefined>(URL_PARAM_KEY.assetInventory) ?? undefined, | ||
[urlStorage] | ||
); | ||
|
||
const setFilterControlsUrlState = useCallback( | ||
(newFilterControls: FilterControlConfig[]) => { | ||
urlStorage.set(URL_PARAM_KEY.assetInventory, newFilterControls); | ||
}, | ||
[urlStorage] | ||
); | ||
|
||
const dataViewSpec = useMemo( | ||
() => | ||
indexPattern | ||
dataView | ||
? { | ||
id: SECURITY_ASSET_INVENTORY_DATA_VIEW.id, | ||
name: SECURITY_ASSET_INVENTORY_DATA_VIEW.name, | ||
// TODO We need this hard-coded id because `ASSET_INVENTORY_INDEX_PATTERN` does not populate the filter dropdowns | ||
id: 'cloud_asset_inventory-2773feaf-50bb-43f8-9fa9-8f9a5f85e566', | ||
name: ASSET_INVENTORY_INDEX_PATTERN, | ||
Comment on lines
+60
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: I needed to hard-code this value to populate the filters. However, in the future it should be |
||
allowNoIndex: true, | ||
title: indexPattern.title, | ||
title: dataView.getIndexPattern(), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This tiny change happens because |
||
timeFieldName: '@timestamp', | ||
} | ||
: null, | ||
[indexPattern] | ||
); | ||
|
||
const handleFilterChanges = useCallback( | ||
(newFilters: Filter[]) => { | ||
if (!onFiltersChange) { | ||
return; | ||
} | ||
const updatedFilters = newFilters.map((filter) => { | ||
return { | ||
...filter, | ||
meta: { | ||
...filter.meta, | ||
disabled: false, | ||
}, | ||
}; | ||
}); | ||
|
||
onFiltersChange(updatedFilters); | ||
}, | ||
[onFiltersChange] | ||
[dataView] | ||
); | ||
|
||
if (!spaceId || !dataViewSpec) { | ||
|
@@ -134,21 +85,14 @@ export const Filters = ({ onFiltersChange }: FiltersProps) => { | |
<EuiSpacer size="l" /> | ||
<FilterGroup | ||
dataViewId={dataViewSpec?.id || null} | ||
onFiltersChange={handleFilterChanges} | ||
onFiltersChange={onFiltersChange} | ||
ruleTypeIds={ASSET_INVENTORY_RULE_TYPE_IDS} | ||
Storage={Storage} | ||
defaultControls={DEFAULT_ASSET_INVENTORY_FILTERS} | ||
chainingSystem="HIERARCHICAL" | ||
spaceId={spaceId} | ||
controlsUrlState={filterControlsUrlState} | ||
setControlsUrlState={setFilterControlsUrlState} | ||
ControlGroupRenderer={ControlGroupRenderer} | ||
maxControls={4} | ||
timeRange={{ | ||
from, | ||
to, | ||
mode: 'absolute', | ||
}} | ||
Comment on lines
-147
to
-151
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @opauloh It seems the best way to solve the time range issue is simply not passing in any There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great, thanks for letting me know! |
||
/> | ||
<EuiSpacer size="l" /> | ||
</> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
/* | ||
* 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. | ||
*/ | ||
|
||
export const MAX_ASSETS_TO_LOAD = 500; // equivalent to MAX_FINDINGS_TO_LOAD in @kbn/cloud-security-posture-common | ||
export const DEFAULT_VISIBLE_ROWS_PER_PAGE = 25; | ||
export const ASSET_INVENTORY_INDEX_PATTERN = 'logs-cloud_asset_inventory.asset_inventory-*'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,202 @@ | ||
/* | ||
* 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 { useInfiniteQuery } from '@tanstack/react-query'; | ||
import { lastValueFrom } from 'rxjs'; | ||
import { number } from 'io-ts'; | ||
import type * as estypes from '@elastic/elasticsearch/lib/api/types'; | ||
import { buildDataTableRecord } from '@kbn/discover-utils'; | ||
import type { EsHitRecord } from '@kbn/discover-utils/types'; | ||
import type { RuntimePrimitiveTypes } from '@kbn/data-views-plugin/common'; | ||
import type { CspBenchmarkRulesStates } from '@kbn/cloud-security-posture-common/schema/rules/latest'; | ||
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; | ||
import { showErrorToast } from '@kbn/cloud-security-posture'; | ||
import type { IKibanaSearchResponse, IKibanaSearchRequest } from '@kbn/search-types'; | ||
import { useGetCspBenchmarkRulesStatesApi } from '@kbn/cloud-security-posture/src/hooks/use_get_benchmark_rules_state_api'; | ||
import type { FindingsBaseEsQuery } from '@kbn/cloud-security-posture'; | ||
import { useKibana } from '../../common/lib/kibana'; | ||
import { MAX_ASSETS_TO_LOAD, ASSET_INVENTORY_INDEX_PATTERN } from '../constants'; | ||
|
||
interface UseAssetsOptions extends FindingsBaseEsQuery { | ||
sort: string[][]; | ||
enabled: boolean; | ||
pageSize: number; | ||
} | ||
|
||
const ASSET_INVENTORY_TABLE_RUNTIME_MAPPING_FIELDS: string[] = ['entity.id', 'entity.name']; | ||
|
||
const getRuntimeMappingsFromSort = (sort: string[][]) => { | ||
return sort | ||
.filter(([field]) => ASSET_INVENTORY_TABLE_RUNTIME_MAPPING_FIELDS.includes(field)) | ||
.reduce((acc, [field]) => { | ||
const type: RuntimePrimitiveTypes = 'keyword'; | ||
|
||
return { | ||
...acc, | ||
[field]: { | ||
type, | ||
}, | ||
}; | ||
}, {}); | ||
}; | ||
|
||
const buildMutedRulesFilter = (rulesStates: CspBenchmarkRulesStates): QueryDslQueryContainer[] => { | ||
const mutedRules = Object.fromEntries( | ||
Object.entries(rulesStates).filter(([_key, value]) => value.muted === true) | ||
); | ||
|
||
const mutedRulesFilterQuery = Object.keys(mutedRules).map((key) => { | ||
// const rule = mutedRules[key]; | ||
return { | ||
bool: { | ||
must: [ | ||
// TODO Determine which rules are mutable | ||
// { term: { 'rule.benchmark.id': rule.benchmark_id } }, | ||
// { term: { 'rule.benchmark.version': rule.benchmark_version } }, | ||
// { term: { 'rule.benchmark.rule_number': rule.rule_number } }, | ||
], | ||
}, | ||
}; | ||
}); | ||
|
||
return mutedRulesFilterQuery; | ||
}; | ||
Comment on lines
+47
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This whole file was duplicated from We might not need to mute any filter query but I left it for now. |
||
|
||
const getMultiFieldsSort = (sort: string[][]) => { | ||
return sort.map(([id, direction]) => { | ||
return { | ||
...getSortField({ field: id, direction }), | ||
}; | ||
}); | ||
}; | ||
|
||
/** | ||
* By default, ES will sort keyword fields in case-sensitive format, the | ||
* following fields are required to have a case-insensitive sorting. | ||
*/ | ||
const fieldsRequiredSortingByPainlessScript = ['entity.name']; // TODO TBD | ||
|
||
/** | ||
* Generates Painless sorting if the given field is matched or returns default sorting | ||
* This painless script will sort the field in case-insensitive manner | ||
*/ | ||
const getSortField = ({ field, direction }: { field: string; direction: string }) => { | ||
if (fieldsRequiredSortingByPainlessScript.includes(field)) { | ||
return { | ||
_script: { | ||
type: 'string', | ||
order: direction, | ||
script: { | ||
source: `doc["${field}"].value.toLowerCase()`, | ||
lang: 'painless', | ||
}, | ||
}, | ||
}; | ||
} | ||
return { [field]: direction }; | ||
}; | ||
|
||
const getAssetsQuery = ( | ||
{ query, sort }: UseAssetsOptions, | ||
rulesStates: CspBenchmarkRulesStates, | ||
pageParam: unknown | ||
) => { | ||
const mutedRulesFilterQuery = buildMutedRulesFilter(rulesStates); | ||
|
||
return { | ||
index: ASSET_INVENTORY_INDEX_PATTERN, | ||
sort: getMultiFieldsSort(sort), | ||
runtime_mappings: getRuntimeMappingsFromSort(sort), | ||
size: MAX_ASSETS_TO_LOAD, | ||
aggs: { | ||
count: { | ||
terms: { | ||
field: 'entity.id', | ||
}, | ||
}, | ||
}, | ||
ignore_unavailable: true, | ||
query: { | ||
...query, | ||
bool: { | ||
...query?.bool, | ||
filter: [...(query?.bool?.filter ?? [])], | ||
must_not: [...(query?.bool?.must_not ?? []), ...mutedRulesFilterQuery], | ||
}, | ||
}, | ||
...(pageParam ? { from: pageParam } : {}), | ||
}; | ||
}; | ||
|
||
interface Asset { | ||
'@timestamp': string; | ||
name: string; | ||
risk: number; | ||
criticality: string; | ||
category: string; | ||
} | ||
|
||
interface AssetsAggs { | ||
count: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>; | ||
} | ||
|
||
type LatestAssetsRequest = IKibanaSearchRequest<estypes.SearchRequest>; | ||
type LatestAssetsResponse = IKibanaSearchResponse<estypes.SearchResponse<Asset, AssetsAggs>>; | ||
|
||
const getAggregationCount = ( | ||
buckets: Array<estypes.AggregationsStringRareTermsBucketKeys | undefined> | ||
) => { | ||
const passed = buckets.find((bucket) => bucket?.key === 'passed'); | ||
const failed = buckets.find((bucket) => bucket?.key === 'failed'); | ||
|
||
return { | ||
passed: passed?.doc_count || 0, | ||
failed: failed?.doc_count || 0, | ||
}; | ||
}; | ||
|
||
export function useFetchData(options: UseAssetsOptions) { | ||
const { | ||
data, | ||
notifications: { toasts }, | ||
} = useKibana().services; | ||
const { data: rulesStates } = useGetCspBenchmarkRulesStatesApi(); | ||
|
||
return useInfiniteQuery( | ||
['asset_inventory', { params: options }, rulesStates], | ||
async ({ pageParam }) => { | ||
const { | ||
rawResponse: { hits, aggregations }, | ||
} = await lastValueFrom( | ||
data.search.search<LatestAssetsRequest, LatestAssetsResponse>({ | ||
// ruleStates always exists since it under the `enabled` dependency. | ||
params: getAssetsQuery(options, rulesStates!, pageParam) as LatestAssetsRequest['params'], // eslint-disable-line @typescript-eslint/no-non-null-assertion | ||
}) | ||
); | ||
if (!aggregations) throw new Error('expected aggregations to be an defined'); | ||
if (!Array.isArray(aggregations.count.buckets)) | ||
throw new Error('expected buckets to be an array'); | ||
|
||
return { | ||
page: hits.hits.map((hit) => buildDataTableRecord(hit as EsHitRecord)), | ||
total: number.is(hits.total) ? hits.total : 0, | ||
count: getAggregationCount(aggregations.count.buckets), | ||
}; | ||
}, | ||
{ | ||
enabled: options.enabled && !!rulesStates, | ||
keepPreviousData: true, | ||
onError: (err: Error) => showErrorToast(toasts, err), | ||
getNextPageParam: (lastPage, allPages) => { | ||
if (lastPage.page.length < options.pageSize) { | ||
return undefined; | ||
} | ||
return allPages.length * options.pageSize; | ||
}, | ||
} | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Previously I propagated
showFilterBar={true}
onto the<SearchBar>
component. Using these props here broke the display value of the selected filter tags, rendering" - "
instead.Then, I switched
showFilterBar={true}
in<SearchBar>
and this was not needed, so I removed it. Please let me know if this might cause an issue