Skip to content
Merged
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 @@ -10,3 +10,5 @@ export {
entriesToConditionEntriesMap,
entriesToConditionEntries,
} from './mappers';

export { parseListIdsFromImportedFile } from './list_id_parser';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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.
*/

/**
* Helper function to extract list ids from an imported file. Does not check whether
* the file is valid, just tries to find list_id fields, so it can be used on UI side
* as a pre-check to ensure only the correct artifact type is being imported.
*
* @param file {File} file to extract list ids from
* @returns {Promise<Set<string>>} set of list ids found in the file
*/
export const parseListIdsFromImportedFile = async (file: File): Promise<Set<string>> =>
(await file.text())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a good idea? To load the entire file into memory in the browser?

My suggestions if you really do need to go through the file in the browser:

  • Consider using a more efficient method for reading file content - File#stream()
  • Should this entire process use AbortController? so that if the user navigates away from the page while this is ongoing, you can efficiently abort?

IMO - this type of check should happen on the server at the API level and not in the browser. Files could be large

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on browser vs server: I agree, however, as we only have one API for both shared exception list pages and endpoint artifact pages, and that API now accepts shared exception lists and endpoint exceptions, blocking endpoint exceptions on it (e.g. by adding a query parameter) would be a breaking change. I added this quick check to avoid that.

on performance: as far as i understood, on modern browsers this shouldn't be an issue. import API allows a maximum of 10k items to be imported, one item is 600-800 byte, depending on the type, so calculating with 1kB per item is still 10MB, not much.

created a test file 13k items, sized 8.6MB, it took 30-45ms on my oldish macbook both in Chrome and Firefox, with the following result anyway:
image

so for now, I think we should be safe with this approach on this feature, that's probably not used extensively in daily routine of the users, without adding more complexity by using streams

.split('\n')
.filter((x) => x.trim() !== '')
.reduce((acc, line) => {
try {
const parsedItem = JSON.parse(line);

if (parsedItem.list_id) {
acc.add(parsedItem.list_id);
}
} catch (e) {
// ignore parsing errors, the API will handle them and return an error for the line
}

return acc;
}, new Set<string>());
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,15 @@ import type {
BulkErrorSchema,
ImportExceptionsResponseSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
import {
ENDPOINT_ARTIFACT_LIST_IDS,
ENDPOINT_ARTIFACT_LISTS,
} from '@kbn/securitysolution-list-constants';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { ToastInput, Toast, ErrorToastOptions } from '@kbn/core-notifications-browser';

import { parseListIdsFromImportedFile } from '../../../common/utils/exception_list_items';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { useImportExceptionList } from '../../hooks/use_import_exception_list';

import * as i18n from '../../translations';
Expand Down Expand Up @@ -65,6 +70,9 @@ export const ImportExceptionListFlyout = React.memo(
const [asNewList, setAsNewList] = useState(false);
const [alreadyExistingItem, setAlreadyExistingItem] = useState(false);
const [endpointListImporting, setEndpointListImporting] = useState(false);
const isEndpointExceptionsMovedFFEnabled = useIsExperimentalFeatureEnabled(
'endpointExceptionsMovedUnderManagement'
);

const resetForm = useCallback(() => {
if (filePickerRef.current?.fileInput) {
Expand All @@ -80,8 +88,21 @@ export const ImportExceptionListFlyout = React.memo(
const { start: importExceptionList, ...importExceptionListState } = useImportExceptionList();
const ctrl = useRef(new AbortController());

const handleImportExceptionList = useCallback(() => {
const handleImportExceptionList = useCallback(async () => {
if (!importExceptionListState.loading && files) {
if (isEndpointExceptionsMovedFFEnabled) {
for (const file of Array.from(files)) {
const listIds = await parseListIdsFromImportedFile(file);

if (ENDPOINT_ARTIFACT_LIST_IDS.some((id) => listIds.has(id))) {
addError(new Error(i18n.IMPORT_ENDPOINT_ARTIFACTS_ERROR_TEXT), {
title: i18n.UPLOAD_ERROR,
});
return;
}
}
}

ctrl.current = new AbortController();

Array.from(files).forEach((file) =>
Expand All @@ -95,7 +116,16 @@ export const ImportExceptionListFlyout = React.memo(
})
);
}
}, [asNewList, files, http, importExceptionList, importExceptionListState.loading, overwrite]);
}, [
importExceptionListState.loading,
files,
isEndpointExceptionsMovedFFEnabled,
addError,
importExceptionList,
http,
overwrite,
asNewList,
]);

const handleImportSuccess = useCallback(
(response: ImportExceptionsResponseSchema) => {
Expand Down Expand Up @@ -137,6 +167,7 @@ export const ImportExceptionListFlyout = React.memo(
if (err.error.message.includes('already exists')) {
setAlreadyExistingItem(true);
if (
!isEndpointExceptionsMovedFFEnabled &&
err.error.message.includes(
`Found that list_id: "${ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id}" already exists`
)
Expand All @@ -157,11 +188,23 @@ export const ImportExceptionListFlyout = React.memo(
importExceptionListState.loading,
importExceptionListState?.result,
importExceptionListState?.result?.errors,
isEndpointExceptionsMovedFFEnabled,
]);

const handleFileChange = useCallback((inputFiles: FileList | null) => {
setFiles(inputFiles ?? null);
}, []);

const handleNewListCheckboxChange = useCallback(() => {
setAsNewList((prev) => !prev);
setOverwrite(false);
}, []);

const handleOverwriteCheckboxChange = useCallback((): void => {
setOverwrite((prev) => !prev);
setAsNewList(false);
}, []);

const importExceptionListFlyoutTitleId = useGeneratedHtmlId({
prefix: 'importExceptionListFlyoutTitle',
});
Expand Down Expand Up @@ -200,27 +243,31 @@ export const ImportExceptionListFlyout = React.memo(
label={i18n.IMPORT_EXCEPTION_LIST_OVERWRITE}
checked={overwrite}
data-test-subj="importExceptionListOverwriteExistingCheckbox"
onChange={(e) => {
setOverwrite(!overwrite);
setAsNewList(false);
}}
onChange={handleOverwriteCheckboxChange}
/>
<EuiToolTip
position="bottom"
content={endpointListImporting ? i18n.IMPORT_EXCEPTION_ENDPOINT_LIST_WARNING : ''}
>
{isEndpointExceptionsMovedFFEnabled ? (
<EuiCheckbox
id={'createNewListCheckbox'}
label={i18n.IMPORT_EXCEPTION_LIST_AS_NEW_LIST}
data-test-subj="importExceptionListCreateNewCheckbox"
checked={asNewList}
disabled={endpointListImporting}
onChange={(e) => {
setAsNewList(!asNewList);
setOverwrite(false);
}}
onChange={handleNewListCheckboxChange}
/>
</EuiToolTip>
) : (
<EuiToolTip
position="bottom"
content={endpointListImporting ? i18n.IMPORT_EXCEPTION_ENDPOINT_LIST_WARNING : ''}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: I find it so confusing to use i18n as an object of messages when by default we use it as the i18n library function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i sort of agree, but this file (and a lot of others not under the 'management' folder) uses this approach 🤷

>
<EuiCheckbox
id={'createNewListCheckbox'}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ok here only because this component will only ever be displayed on the page once, but normally - you would want to assign HTML id's using EUI's useGeneratedHtmlId () hook to ensure no collision

label={i18n.IMPORT_EXCEPTION_LIST_AS_NEW_LIST}
data-test-subj="importExceptionListCreateNewCheckbox"
checked={asNewList}
disabled={endpointListImporting}
onChange={handleNewListCheckboxChange}
/>
</EuiToolTip>
)}
</>
)}
</EuiFlyoutBody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ export const IMPORT_EXCEPTION_LIST_AS_NEW_LIST = i18n.translate(
}
);

export const IMPORT_ENDPOINT_ARTIFACTS_ERROR_TEXT = i18n.translate(
'xpack.securitySolution.exceptionsTable.importEndpointArtifactsErrorText',
{
defaultMessage:
'You can only import shared exception lists here, but at least one of the imported files contains endpoint artifacts. Import endpoint artifacts from their dedicated pages instead.',
}
);

export const IMPORT_EXCEPTION_ENDPOINT_LIST_WARNING = i18n.translate(
'xpack.securitySolution.exceptionsTable.importExceptionEndpointListWarning',
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

import React, { memo, useCallback, useMemo, useState, useEffect } from 'react';

import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import type {
BulkErrorSchema,
ExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { EuiButton, EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui';
import type { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout';
import { useLocation } from 'react-router-dom';
Expand Down Expand Up @@ -52,6 +55,7 @@ import { BackToExternalAppButton } from '../back_to_external_app_button';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { ArtifactImportFlyout } from './components/artifact_import_flyout';
import { useIsImportFlyoutOpened } from './hooks/use_is_import_flyout_opened';
import { ArtifactImportErrorsModal } from './components/artifact_import_errors_modal';

type ArtifactEntryCardType = typeof ArtifactEntryCard;

Expand Down Expand Up @@ -118,6 +122,8 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
useIsImportFlyoutOpened(allowCardCreateAction) &&
areEndpointExceptionsMovedUnderManagementFFEnabled;

const [importErrors, setImportErrors] = useState<BulkErrorSchema[] | undefined>(undefined);

const setUrlParams = useSetUrlParams();
const {
urlParams: { filter, includedPolicies },
Expand Down Expand Up @@ -298,6 +304,12 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
refetchListData();
}, [closeImportFlyout, refetchListData]);

const handleImportFlyoutOnShowErrors = useCallback((errors: BulkErrorSchema[]) => {
setImportErrors(errors);
}, []);

const handleCloseImportErrorsModal = useCallback(() => setImportErrors(undefined), []);

const description = useMemo(() => {
const subtitleText = labels.pageAboutInfo ? (
<span data-test-subj="header-panel-subtitle">{labels.pageAboutInfo}</span>
Expand Down Expand Up @@ -409,11 +421,16 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
<ArtifactImportFlyout
onCancel={closeImportFlyout}
onSuccess={handleImportFlyoutOnSuccess}
onShowErrors={handleImportFlyoutOnShowErrors}
apiClient={apiClient}
labels={labels}
/>
)}

{importErrors && (
<ArtifactImportErrorsModal errors={importErrors} onClose={handleCloseImportErrorsModal} />
)}

{selectedItemForDelete && (
<ArtifactDeleteModal
apiClient={apiClient}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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 React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonEmpty,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiText,
useGeneratedHtmlId,
} from '@elastic/eui';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';

interface ArtifactImportConfirmModalProps {
onCancel: () => void;
onConfirm: () => void;
isLoading: boolean;
'data-test-subj'?: string;
}

export const ArtifactImportConfirmModal: React.FC<ArtifactImportConfirmModalProps> = ({
onCancel,
onConfirm,
isLoading,
'data-test-subj': dataTestSubj = 'artifactImportConfirmModal',
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const modalTitleId = useGeneratedHtmlId();

return (
<EuiModal onClose={onCancel} aria-labelledby={modalTitleId} data-test-subj={getTestId()}>
<EuiModalHeader>
<EuiModalHeaderTitle id={modalTitleId}>
{i18n.translate('xpack.securitySolution.artifactListPage.importConfirmModal.title', {
defaultMessage: 'Import artifacts?',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText>
<p>
{i18n.translate('xpack.securitySolution.artifactListPage.importConfirmModal.info', {
defaultMessage:
"This will add new artifacts to your list. If an artifact you're importing already exists, the existing version will be kept, and the import of that artifact will be skipped.",
})}
</p>
</EuiText>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel} data-test-subj={getTestId('cancelButton')}>
{i18n.translate(
'xpack.securitySolution.artifactListPage.importConfirmModal.cancelButtonTitle',
{ defaultMessage: 'Cancel' }
)}
</EuiButtonEmpty>

<EuiButton
fill
color="primary"
onClick={onConfirm}
isLoading={isLoading}
data-test-subj={getTestId('confirmButton')}
>
{i18n.translate(
'xpack.securitySolution.artifactListPage.importConfirmModal.confirmButtonTitle',
{ defaultMessage: 'Import' }
)}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

ArtifactImportConfirmModal.displayName = 'ArtifactImportConfirmModal';
Loading
Loading