[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:
Paul Tavares 2025-04-08 09:28:05 -04:00 committed by GitHub
parent 587add8e60
commit 970e9fe4a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 152 additions and 32 deletions

View file

@ -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,
});
};

View file

@ -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),
})
);
});
});
});

View file

@ -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,

View file

@ -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;
};

View file

@ -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;
}
>;
/**

View file

@ -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}`);

View file

@ -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;

View file

@ -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 };
};

View file

@ -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

View file

@ -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`)