mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution][Endpoint] Update artifact export api in support of space awareness (#217102)
## Summary ### Lists plugin: - Add the ability to pass a KQL `filter` to the exportExceptionListAndItems()` server-side service - NOTE: this `filter` property is NOT exposed via the public API. Only used internally - Fixes the `getExcetionList()` service method to ensure that if a list if not found using the `id`, that it attempts to then find it using `list_id` if that was provided on input to the method. ### Security Solution: - Export for endpoint artifacts was updated with additional filtering criteria to ensure that only artifact accessible in active space are included in the export
This commit is contained in:
parent
587add8e60
commit
970e9fe4a3
10 changed files with 152 additions and 32 deletions
|
@ -1049,32 +1049,21 @@ export class ExceptionListClient {
|
|||
* @param options.includeExpiredExceptions include or exclude expired TTL exception items
|
||||
* @returns the ndjson of the list and items to export or null if none exists
|
||||
*/
|
||||
public exportExceptionListAndItems = async ({
|
||||
listId,
|
||||
id,
|
||||
namespaceType,
|
||||
includeExpiredExceptions,
|
||||
}: ExportExceptionListAndItemsOptions): Promise<ExportExceptionListAndItemsReturn | null> => {
|
||||
public exportExceptionListAndItems = async (
|
||||
options: ExportExceptionListAndItemsOptions
|
||||
): Promise<ExportExceptionListAndItemsReturn | null> => {
|
||||
const { savedObjectsClient } = this;
|
||||
|
||||
if (this.enableServerExtensionPoints) {
|
||||
await this.serverExtensionsClient.pipeRun(
|
||||
'exceptionsListPreExport',
|
||||
{
|
||||
id,
|
||||
includeExpiredExceptions,
|
||||
listId,
|
||||
namespaceType,
|
||||
},
|
||||
options,
|
||||
this.getServerExtensionCallbackContext()
|
||||
);
|
||||
}
|
||||
|
||||
return exportExceptionListAndItems({
|
||||
id,
|
||||
includeExpiredExceptions,
|
||||
listId,
|
||||
namespaceType,
|
||||
...options,
|
||||
savedObjectsClient,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -65,5 +65,33 @@ describe('export_exception_list_and_items', () => {
|
|||
missing_exception_lists_count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test.each`
|
||||
title | includeExpired | expectedStringMatch
|
||||
${'with expired exceptions'} | ${true} | ${'^tags: foo$'}
|
||||
${'with OUT expired exceptions'} | ${false} | ${'^\\(tags: foo\\) AND \\(exception-list.attributes.expire_time'}
|
||||
`('it should use a `filter` $title', async ({ includeExpired, expectedStringMatch }) => {
|
||||
(getExceptionList as jest.Mock).mockResolvedValue(getExceptionListSchemaMock());
|
||||
(findExceptionListItemPointInTimeFinder as jest.Mock).mockImplementationOnce(
|
||||
({ executeFunctionOnStream }) => {
|
||||
executeFunctionOnStream({ data: [getExceptionListItemSchemaMock()] });
|
||||
}
|
||||
);
|
||||
|
||||
await exportExceptionListAndItems({
|
||||
filter: 'tags: foo',
|
||||
id: '123',
|
||||
includeExpiredExceptions: includeExpired,
|
||||
listId: 'non-existent',
|
||||
namespaceType: 'single',
|
||||
savedObjectsClient: savedObjectsClientMock.create(),
|
||||
});
|
||||
|
||||
expect(findExceptionListItemPointInTimeFinder).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filter: expect.stringMatching(expectedStringMatch),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -26,6 +26,8 @@ interface ExportExceptionListAndItemsOptions {
|
|||
savedObjectsClient: SavedObjectsClientContract;
|
||||
namespaceType: NamespaceType;
|
||||
includeExpiredExceptions: boolean;
|
||||
/** Optional KQL filter to be applied to the data that will be retrieved for export */
|
||||
filter?: string;
|
||||
}
|
||||
|
||||
export interface ExportExceptionListAndItemsReturn {
|
||||
|
@ -39,6 +41,7 @@ export const exportExceptionListAndItems = async ({
|
|||
namespaceType,
|
||||
includeExpiredExceptions,
|
||||
savedObjectsClient,
|
||||
filter: dataFilter = undefined,
|
||||
}: ExportExceptionListAndItemsOptions): Promise<ExportExceptionListAndItemsReturn | null> => {
|
||||
const exceptionList = await getExceptionList({
|
||||
id,
|
||||
|
@ -56,9 +59,17 @@ export const exportExceptionListAndItems = async ({
|
|||
exceptionItems = [...exceptionItems, ...response.data];
|
||||
};
|
||||
const savedObjectPrefix = getSavedObjectType({ namespaceType });
|
||||
const filter = includeExpiredExceptions
|
||||
? undefined
|
||||
: `(${savedObjectPrefix}.attributes.expire_time > "${new Date().toISOString()}" OR NOT ${savedObjectPrefix}.attributes.expire_time: *)`;
|
||||
let filter = dataFilter;
|
||||
|
||||
if (!includeExpiredExceptions) {
|
||||
const noExpiredItemsFilter = `(${savedObjectPrefix}.attributes.expire_time > "${new Date().toISOString()}" OR NOT ${savedObjectPrefix}.attributes.expire_time: *)`;
|
||||
|
||||
if (filter) {
|
||||
filter = `(${filter}) AND ${noExpiredItemsFilter}`;
|
||||
} else {
|
||||
filter = noExpiredItemsFilter;
|
||||
}
|
||||
}
|
||||
|
||||
await findExceptionListItemPointInTimeFinder({
|
||||
executeFunctionOnStream,
|
||||
|
|
|
@ -32,18 +32,19 @@ export const getExceptionList = async ({
|
|||
namespaceType,
|
||||
}: GetExceptionListOptions): Promise<ExceptionListSchema | null> => {
|
||||
const savedObjectType = getSavedObjectType({ namespaceType });
|
||||
|
||||
if (id != null) {
|
||||
try {
|
||||
const savedObject = await savedObjectsClient.get<ExceptionListSoSchema>(savedObjectType, id);
|
||||
return transformSavedObjectToExceptionList({ savedObject });
|
||||
} catch (err) {
|
||||
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
|
||||
return null;
|
||||
} else {
|
||||
if (!SavedObjectsErrorHelpers.isNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else if (listId != null) {
|
||||
}
|
||||
|
||||
if (listId != null) {
|
||||
const savedObject = await savedObjectsClient.find<ExceptionListSoSchema>({
|
||||
filter: `${savedObjectType}.attributes.list_type: list`,
|
||||
perPage: 1,
|
||||
|
@ -60,7 +61,7 @@ export const getExceptionList = async ({
|
|||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
@ -125,7 +125,14 @@ export type ExceptionsListPreMultiListFindServerExtension = ServerExtensionPoint
|
|||
*/
|
||||
export type ExceptionsListPreExportServerExtension = ServerExtensionPointDefinition<
|
||||
'exceptionsListPreExport',
|
||||
ExportExceptionListAndItemsOptions
|
||||
ExportExceptionListAndItemsOptions & {
|
||||
/**
|
||||
* Used internally only by the `ExceptionListClient.exportExceptionListAndItems` to provide registered
|
||||
* server extension points the ability to adjust (via KQL filters) the data that can be exported.
|
||||
* (In support of Elastic Defend support for space awareness)
|
||||
*/
|
||||
filter?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
|
|
|
@ -61,6 +61,7 @@ export const ensureArtifactListExists = memoize(
|
|||
list_id: ENDPOINT_LIST_ID,
|
||||
type: ExceptionListTypeEnum.ENDPOINT,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown Artifact list: ${artifactType}`);
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
*/
|
||||
|
||||
import type { ExceptionsListPreExportServerExtension } from '@kbn/lists-plugin/server';
|
||||
import { EndpointArtifactExceptionValidationError } from '../validators/errors';
|
||||
import { stringify } from '../../../endpoint/utils/stringify';
|
||||
import { buildSpaceDataFilter } from '../utils';
|
||||
import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services';
|
||||
import {
|
||||
BlocklistValidator,
|
||||
|
@ -18,6 +21,8 @@ import {
|
|||
export const getExceptionsPreExportHandler = (
|
||||
endpointAppContextService: EndpointAppContextService
|
||||
): ExceptionsListPreExportServerExtension['callback'] => {
|
||||
const logger = endpointAppContextService.createLogger('listsExceptionsPreExportHandler');
|
||||
|
||||
return async function ({ data, context: { request, exceptionListClient } }) {
|
||||
if (data.namespaceType !== 'agnostic') {
|
||||
return data;
|
||||
|
@ -25,6 +30,7 @@ export const getExceptionsPreExportHandler = (
|
|||
|
||||
const { listId: maybeListId, id } = data;
|
||||
let listId: string | null | undefined = maybeListId;
|
||||
let isEndpointArtifact = false;
|
||||
|
||||
if (!listId && id) {
|
||||
listId = (await exceptionListClient.getExceptionList(data))?.list_id ?? null;
|
||||
|
@ -36,35 +42,52 @@ export const getExceptionsPreExportHandler = (
|
|||
|
||||
// Validate Trusted Applications
|
||||
if (TrustedAppValidator.isTrustedApp({ listId })) {
|
||||
isEndpointArtifact = true;
|
||||
await new TrustedAppValidator(endpointAppContextService, request).validatePreExport();
|
||||
return data;
|
||||
}
|
||||
|
||||
// Host Isolation Exceptions validations
|
||||
if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) {
|
||||
isEndpointArtifact = true;
|
||||
await new HostIsolationExceptionsValidator(
|
||||
endpointAppContextService,
|
||||
request
|
||||
).validatePreExport();
|
||||
return data;
|
||||
}
|
||||
|
||||
// Event Filter validations
|
||||
if (EventFilterValidator.isEventFilter({ listId })) {
|
||||
isEndpointArtifact = true;
|
||||
await new EventFilterValidator(endpointAppContextService, request).validatePreExport();
|
||||
return data;
|
||||
}
|
||||
|
||||
// Validate Blocklists
|
||||
if (BlocklistValidator.isBlocklist({ listId })) {
|
||||
isEndpointArtifact = true;
|
||||
await new BlocklistValidator(endpointAppContextService, request).validatePreExport();
|
||||
return data;
|
||||
}
|
||||
|
||||
// Validate Endpoint Exceptions
|
||||
if (EndpointExceptionsValidator.isEndpointException({ listId })) {
|
||||
isEndpointArtifact = true;
|
||||
await new EndpointExceptionsValidator(endpointAppContextService, request).validatePreExport();
|
||||
return data;
|
||||
}
|
||||
|
||||
// If space awareness is enabled, add space filter to export options
|
||||
if (
|
||||
isEndpointArtifact &&
|
||||
endpointAppContextService.experimentalFeatures.endpointManagementSpaceAwarenessEnabled
|
||||
) {
|
||||
if (!request) {
|
||||
throw new EndpointArtifactExceptionValidationError(`Missing HTTP Request object`);
|
||||
}
|
||||
|
||||
const spaceDataFilter = (await buildSpaceDataFilter(endpointAppContextService, request))
|
||||
.filter;
|
||||
|
||||
data.filter = spaceDataFilter + (data.filter ? ` AND (${data.filter})` : '');
|
||||
|
||||
logger.debug(`Export request after adding space filter:\n${stringify(data)}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
|
|
@ -70,7 +70,7 @@ export const buildSpaceDataFilter = async (
|
|||
)
|
||||
)`;
|
||||
|
||||
logger.debug(`Filter for space id ${spaceId}:\n${spaceVisibleDataFilter}`);
|
||||
logger.debug(`Filter for space id [${spaceId}]:\n${spaceVisibleDataFilter}`);
|
||||
|
||||
return { filter: spaceVisibleDataFilter };
|
||||
};
|
||||
|
|
|
@ -26,6 +26,7 @@ import type {
|
|||
} from '@kbn/securitysolution-io-ts-list-types';
|
||||
import { Role } from '@kbn/security-plugin-types-common';
|
||||
import { GLOBAL_ARTIFACT_TAG } from '@kbn/security-solution-plugin/common/endpoint/service/artifacts';
|
||||
import { binaryToString } from '../../../detections_response/utils';
|
||||
import { PolicyTestResourceInfo } from '../../../../../security_solution_endpoint/services/endpoint_policy';
|
||||
import { createSupertestErrorLogger } from '../../utils';
|
||||
import { ArtifactTestData } from '../../../../../security_solution_endpoint/services/endpoint_artifacts';
|
||||
|
@ -300,6 +301,43 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
expect(response.body as ExceptionListSummarySchema).to.eql(expectedSummaryResponse);
|
||||
});
|
||||
|
||||
it('should export only artifact accessible in space', async () => {
|
||||
const response = await supertestArtifactManager
|
||||
.post(addSpaceIdToPath('/', spaceOneId, `${EXCEPTION_LIST_URL}/_export`))
|
||||
.set('elastic-api-version', '2023-10-31')
|
||||
.set('x-elastic-internal-origin', 'kibana')
|
||||
.set('kbn-xsrf', 'true')
|
||||
.on('error', createSupertestErrorLogger(log))
|
||||
.query({
|
||||
id: listInfo.id,
|
||||
list_id: listInfo.id,
|
||||
include_expired_exceptions: true,
|
||||
namespace_type: 'agnostic',
|
||||
})
|
||||
.send()
|
||||
.expect(200)
|
||||
.parse(binaryToString);
|
||||
|
||||
const exportedRecords = (response.body as Buffer)
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter((line) => !!line)
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
log.verbose(
|
||||
`Export of [${listInfo.id}] for space [${spaceOneId}]:\n${JSON.stringify(
|
||||
exportedRecords,
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
|
||||
// The last record in the export is the summary
|
||||
const exportSummary = exportedRecords[exportedRecords.length - 1];
|
||||
|
||||
expect(exportSummary.exported_exception_list_item_count).to.equal(3);
|
||||
});
|
||||
|
||||
describe('and user does NOT have global artifact management privilege', () => {
|
||||
it('should error when attempting to create artifact with additional owner space id tags', async () => {
|
||||
await supertestArtifactManager
|
||||
|
|
|
@ -90,6 +90,28 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
expect(bodyToCompare).to.eql(outputtedList);
|
||||
});
|
||||
|
||||
it('should be able to read a single list when both `id` and `list_id` are provided, but `id` is invalid', async () => {
|
||||
// create a simple exception list to read
|
||||
const { body: createListBody } = await supertest
|
||||
.post(EXCEPTION_LIST_URL)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(getCreateExceptionListMinimalSchemaMockWithoutId())
|
||||
.expect(200);
|
||||
|
||||
const { body } = await supertest
|
||||
.get(`${EXCEPTION_LIST_URL}?id=some_invalid_id&list_id=${createListBody.list_id}`)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
|
||||
const outputtedList: Partial<ExceptionListSchema> = {
|
||||
...getExceptionResponseMockWithoutAutoGeneratedValues(await utils.getUsername()),
|
||||
list_id: body.list_id,
|
||||
};
|
||||
|
||||
const bodyToCompare = removeExceptionListServerGeneratedProperties(body);
|
||||
expect(bodyToCompare).to.eql(outputtedList);
|
||||
});
|
||||
|
||||
it('should return 404 if given a fake id', async () => {
|
||||
const { body } = await supertest
|
||||
.get(`${EXCEPTION_LIST_URL}?id=c1e1b359-7ac1-4e96-bc81-c683c092436f`)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue