[Security Solution] [Attack discovery] Improves Attack discovery code coverage (#186679)

## [Security Solution] [Attack discovery] Improves Attack discovery code coverage

### Summary

This PR improves unit test coverage for the [Attack discovery](https://github.com/elastic/kibana/pull/181818) feature.

### Desk testing

Run `node scripts/jest --watch x-pack/plugins/security_solution/public/attack_discovery --coverage`
This commit is contained in:
Andrew Macri 2024-06-25 19:42:13 -04:00 committed by GitHub
parent ba1221904c
commit f775a6a3f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 3955 additions and 343 deletions

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AuthenticatedUser } from '@kbn/core-security-common';
import moment from 'moment';
import {
@ -30,6 +31,12 @@ import {
import { coreMock } from '@kbn/core/server/mocks';
import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms';
import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock';
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import {
getAnonymizationFieldMock,
getUpdateAnonymizationFieldSchemaMock,
} from '../../__mocks__/anonymization_fields_schema.mock';
jest.mock('lodash/fp', () => ({
uniq: jest.fn((arr) => Array.from(new Set(arr))),
@ -61,6 +68,7 @@ const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const mockLogger = loggerMock.create();
const mockTelemetry = coreMock.createSetup().analytics;
const mockError = new Error('Test error');
const mockAuthenticatedUser = {
username: 'user',
profile_uid: '1234',
@ -69,13 +77,25 @@ const mockAuthenticatedUser = {
name: 'my_realm_name',
},
} as AuthenticatedUser;
const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
provider: OpenAiProviderType.OpenAi,
};
const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0];
const mockActions: ActionsPluginStart = {} as ActionsPluginStart;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockRequest: KibanaRequest<unknown, unknown, any, any> = {} as unknown as KibanaRequest<
unknown,
unknown,
any, // eslint-disable-line @typescript-eslint/no-explicit-any
any // eslint-disable-line @typescript-eslint/no-explicit-any
>;
describe('helpers', () => {
const date = '2024-03-28T22:27:28.000Z';
beforeAll(() => {
@ -92,6 +112,22 @@ describe('helpers', () => {
updateAttackDiscovery.mockResolvedValue({});
});
describe('getAssistantToolParams', () => {
const alertsIndexPattern = '.alerts-security.alerts-default';
const esClient = elasticsearchClientMock.createElasticsearchClient();
const langChainTimeout = 1000;
const latestReplacements = {};
const llm = new ActionsClientLlm({
actions: mockActions,
connectorId: 'test-connecter-id',
llmType: 'bedrock',
logger: mockLogger,
request: mockRequest,
temperature: 0,
timeout: 580000,
});
const onNewReplacements = jest.fn();
const size = 20;
const mockParams = {
actions: {} as unknown as ActionsPluginStart,
alertsIndexPattern: 'alerts-*',
@ -127,364 +163,439 @@ describe('helpers', () => {
...REQUIRED_FOR_ATTACK_DISCOVERY,
]);
});
});
describe('addGenerationInterval', () => {
const generationInterval = { date: '2024-01-01T00:00:00Z', durationMs: 1000 };
const existingIntervals = [
{ date: '2024-01-02T00:00:00Z', durationMs: 2000 },
{ date: '2024-01-03T00:00:00Z', durationMs: 3000 },
];
it('returns the expected AssistantToolParams when anonymizationFields are provided', () => {
const anonymizationFields = [
getAnonymizationFieldMock(getUpdateAnonymizationFieldSchemaMock()),
];
it('should add new interval and maintain length within MAX_GENERATION_INTERVALS', () => {
const result = addGenerationInterval(existingIntervals, generationInterval);
expect(result.length).toBeLessThanOrEqual(5);
expect(result).toContain(generationInterval);
const result = getAssistantToolParams({
actions: mockParams.actions,
alertsIndexPattern,
apiConfig: mockApiConfig,
anonymizationFields,
connectorTimeout: 1000,
latestReplacements,
esClient,
langChainTimeout,
logger: mockLogger,
onNewReplacements,
request: mockRequest,
size,
});
expect(result).toEqual({
alertsIndexPattern,
anonymizationFields: [...anonymizationFields, ...REQUIRED_FOR_ATTACK_DISCOVERY],
isEnabledKnowledgeBase: false,
chain: undefined,
esClient,
langChainTimeout,
llm,
logger: mockLogger,
modelExists: false,
onNewReplacements,
replacements: latestReplacements,
request: mockRequest,
size,
});
});
it('should remove the oldest interval if exceeding MAX_GENERATION_INTERVALS', () => {
const longExistingIntervals = [...Array(5)].map((_, i) => ({
date: `2024-01-0${i + 2}T00:00:00Z`,
durationMs: (i + 2) * 1000,
}));
const result = addGenerationInterval(longExistingIntervals, generationInterval);
expect(result.length).toBe(5);
expect(result).not.toContain(longExistingIntervals[4]);
});
});
it('returns the expected AssistantToolParams when anonymizationFields is undefined', () => {
const anonymizationFields = undefined;
describe('updateAttackDiscoveryStatusToRunning', () => {
it('should update existing attack discovery to running', async () => {
const existingAd = { id: 'existing-id', backingIndex: 'index' };
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(existingAd);
const result = await updateAttackDiscoveryStatusToRunning(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig
);
expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({
connectorId: mockApiConfig.connectorId,
authenticatedUser: mockAuthenticatedUser,
const result = getAssistantToolParams({
actions: mockParams.actions,
alertsIndexPattern,
apiConfig: mockApiConfig,
anonymizationFields,
connectorTimeout: 1000,
latestReplacements,
esClient,
langChainTimeout,
logger: mockLogger,
onNewReplacements,
request: mockRequest,
size,
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: expect.objectContaining({
status: attackDiscoveryStatus.running,
}),
authenticatedUser: mockAuthenticatedUser,
expect(result).toEqual({
alertsIndexPattern,
anonymizationFields: [...REQUIRED_FOR_ATTACK_DISCOVERY],
isEnabledKnowledgeBase: false,
chain: undefined,
esClient,
langChainTimeout,
llm,
logger: mockLogger,
modelExists: false,
onNewReplacements,
replacements: latestReplacements,
request: mockRequest,
size,
});
expect(result).toEqual({ attackDiscoveryId: existingAd.id, currentAd: existingAd });
});
it('should create a new attack discovery if none exists', async () => {
const newAd = { id: 'new-id', backingIndex: 'index' };
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
createAttackDiscovery.mockResolvedValue(newAd);
describe('addGenerationInterval', () => {
const generationInterval = { date: '2024-01-01T00:00:00Z', durationMs: 1000 };
const existingIntervals = [
{ date: '2024-01-02T00:00:00Z', durationMs: 2000 },
{ date: '2024-01-03T00:00:00Z', durationMs: 3000 },
];
const result = await updateAttackDiscoveryStatusToRunning(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig
);
expect(createAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryCreate: expect.objectContaining({
status: attackDiscoveryStatus.running,
}),
authenticatedUser: mockAuthenticatedUser,
it('should add new interval and maintain length within MAX_GENERATION_INTERVALS', () => {
const result = addGenerationInterval(existingIntervals, generationInterval);
expect(result.length).toBeLessThanOrEqual(5);
expect(result).toContain(generationInterval);
});
it('should remove the oldest interval if exceeding MAX_GENERATION_INTERVALS', () => {
const longExistingIntervals = [...Array(5)].map((_, i) => ({
date: `2024-01-0${i + 2}T00:00:00Z`,
durationMs: (i + 2) * 1000,
}));
const result = addGenerationInterval(longExistingIntervals, generationInterval);
expect(result.length).toBe(5);
expect(result).not.toContain(longExistingIntervals[4]);
});
expect(result).toEqual({ attackDiscoveryId: newAd.id, currentAd: newAd });
});
it('should throw an error if updating or creating attack discovery fails', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
createAttackDiscovery.mockResolvedValue(null);
describe('updateAttackDiscoveryStatusToRunning', () => {
it('should update existing attack discovery to running', async () => {
const existingAd = { id: 'existing-id', backingIndex: 'index' };
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(existingAd);
await expect(
updateAttackDiscoveryStatusToRunning(mockDataClient, mockAuthenticatedUser, mockApiConfig)
).rejects.toThrow('Could not create attack discovery for connectorId: connector-id');
});
});
const result = await updateAttackDiscoveryStatusToRunning(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig
);
describe('updateAttackDiscoveryStatusToCanceled', () => {
const existingAd = {
id: 'existing-id',
backingIndex: 'index',
status: attackDiscoveryStatus.running,
};
it('should update existing attack discovery to canceled', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(existingAd);
const result = await updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
);
expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({
connectorId: mockApiConfig.connectorId,
authenticatedUser: mockAuthenticatedUser,
expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({
connectorId: mockApiConfig.connectorId,
authenticatedUser: mockAuthenticatedUser,
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: expect.objectContaining({
status: attackDiscoveryStatus.running,
}),
authenticatedUser: mockAuthenticatedUser,
});
expect(result).toEqual({ attackDiscoveryId: existingAd.id, currentAd: existingAd });
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: expect.objectContaining({
status: attackDiscoveryStatus.canceled,
}),
authenticatedUser: mockAuthenticatedUser,
it('should create a new attack discovery if none exists', async () => {
const newAd = { id: 'new-id', backingIndex: 'index' };
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
createAttackDiscovery.mockResolvedValue(newAd);
const result = await updateAttackDiscoveryStatusToRunning(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig
);
expect(createAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryCreate: expect.objectContaining({
status: attackDiscoveryStatus.running,
}),
authenticatedUser: mockAuthenticatedUser,
});
expect(result).toEqual({ attackDiscoveryId: newAd.id, currentAd: newAd });
});
it('should throw an error if updating or creating attack discovery fails', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
createAttackDiscovery.mockResolvedValue(null);
await expect(
updateAttackDiscoveryStatusToRunning(mockDataClient, mockAuthenticatedUser, mockApiConfig)
).rejects.toThrow('Could not create attack discovery for connectorId: connector-id');
});
expect(result).toEqual(existingAd);
});
it('should throw an error if attack discovery is not running', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue({
...existingAd,
status: attackDiscoveryStatus.succeeded,
});
await expect(
updateAttackDiscoveryStatusToCanceled(
describe('updateAttackDiscoveryStatusToCanceled', () => {
const existingAd = {
id: 'existing-id',
backingIndex: 'index',
status: attackDiscoveryStatus.running,
};
it('should update existing attack discovery to canceled', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(existingAd);
const result = await updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow(
'Connector id connector-id does not have a running attack discovery, and therefore cannot be canceled.'
);
});
);
it('should throw an error if attack discovery does not exist', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
await expect(
updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow('Could not find attack discovery for connector id: connector-id');
});
it('should throw error if updateAttackDiscovery returns null', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(null);
expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({
connectorId: mockApiConfig.connectorId,
authenticatedUser: mockAuthenticatedUser,
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: expect.objectContaining({
status: attackDiscoveryStatus.canceled,
}),
authenticatedUser: mockAuthenticatedUser,
});
expect(result).toEqual(existingAd);
});
await expect(
updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow('Could not update attack discovery for connector id: connector-id');
});
});
describe('updateAttackDiscoveries', () => {
const mockAttackDiscoveryId = 'attack-discovery-id';
const mockLatestReplacements = {};
const mockRawAttackDiscoveries = JSON.stringify({
alertsContextCount: 5,
attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }],
});
const mockSize = 10;
const mockStartTime = moment('2024-03-28T22:25:28.000Z');
const mockArgs = {
apiConfig: mockApiConfig,
attackDiscoveryId: mockAttackDiscoveryId,
authenticatedUser: mockAuthenticatedUser,
dataClient: mockDataClient,
latestReplacements: mockLatestReplacements,
logger: mockLogger,
rawAttackDiscoveries: mockRawAttackDiscoveries,
size: mockSize,
startTime: mockStartTime,
telemetry: mockTelemetry,
};
it('should update attack discoveries and report success telemetry', async () => {
await updateAttackDiscoveries(mockArgs);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
alertsContextCount: 5,
attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }],
it('should throw an error if attack discovery is not running', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue({
...existingAd,
status: attackDiscoveryStatus.succeeded,
id: mockAttackDiscoveryId,
replacements: mockLatestReplacements,
backingIndex: mockCurrentAd.backingIndex,
generationIntervals: [{ date, durationMs: 120000 }, ...mockCurrentAd.generationIntervals],
},
authenticatedUser: mockAuthenticatedUser,
});
await expect(
updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow(
'Connector id connector-id does not have a running attack discovery, and therefore cannot be canceled.'
);
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', {
actionTypeId: mockApiConfig.actionTypeId,
alertsContextCount: 5,
alertsCount: 3,
configuredAlertsCount: mockSize,
discoveriesGenerated: 2,
durationMs: 120000,
model: mockApiConfig.model,
provider: mockApiConfig.provider,
it('should throw an error if attack discovery does not exist', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
await expect(
updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow('Could not find attack discovery for connector id: connector-id');
});
it('should throw error if updateAttackDiscovery returns null', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(null);
await expect(
updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow('Could not update attack discovery for connector id: connector-id');
});
});
it('should update attack discoveries without generation interval if no discoveries are found', async () => {
const noDiscoveriesRaw = JSON.stringify({
alertsContextCount: 0,
attackDiscoveries: [],
describe('updateAttackDiscoveries', () => {
const mockAttackDiscoveryId = 'attack-discovery-id';
const mockLatestReplacements = {};
const mockRawAttackDiscoveries = JSON.stringify({
alertsContextCount: 5,
attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }],
});
const mockSize = 10;
const mockStartTime = moment('2024-03-28T22:25:28.000Z');
const mockArgs = {
apiConfig: mockApiConfig,
attackDiscoveryId: mockAttackDiscoveryId,
authenticatedUser: mockAuthenticatedUser,
dataClient: mockDataClient,
latestReplacements: mockLatestReplacements,
logger: mockLogger,
rawAttackDiscoveries: mockRawAttackDiscoveries,
size: mockSize,
startTime: mockStartTime,
telemetry: mockTelemetry,
};
it('should update attack discoveries and report success telemetry', async () => {
await updateAttackDiscoveries(mockArgs);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
alertsContextCount: 5,
attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }],
status: attackDiscoveryStatus.succeeded,
id: mockAttackDiscoveryId,
replacements: mockLatestReplacements,
backingIndex: mockCurrentAd.backingIndex,
generationIntervals: [
{ date, durationMs: 120000 },
...mockCurrentAd.generationIntervals,
],
},
authenticatedUser: mockAuthenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', {
actionTypeId: mockApiConfig.actionTypeId,
alertsContextCount: 5,
alertsCount: 3,
configuredAlertsCount: mockSize,
discoveriesGenerated: 2,
durationMs: 120000,
model: mockApiConfig.model,
provider: mockApiConfig.provider,
});
});
await updateAttackDiscoveries({
...mockArgs,
rawAttackDiscoveries: noDiscoveriesRaw,
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
it('should update attack discoveries without generation interval if no discoveries are found', async () => {
const noDiscoveriesRaw = JSON.stringify({
alertsContextCount: 0,
attackDiscoveries: [],
status: attackDiscoveryStatus.succeeded,
id: mockAttackDiscoveryId,
replacements: mockLatestReplacements,
backingIndex: mockCurrentAd.backingIndex,
},
});
await updateAttackDiscoveries({
...mockArgs,
rawAttackDiscoveries: noDiscoveriesRaw,
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
alertsContextCount: 0,
attackDiscoveries: [],
status: attackDiscoveryStatus.succeeded,
id: mockAttackDiscoveryId,
replacements: mockLatestReplacements,
backingIndex: mockCurrentAd.backingIndex,
},
authenticatedUser: mockAuthenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', {
actionTypeId: mockApiConfig.actionTypeId,
alertsContextCount: 0,
alertsCount: 0,
configuredAlertsCount: mockSize,
discoveriesGenerated: 0,
durationMs: 120000,
model: mockApiConfig.model,
provider: mockApiConfig.provider,
});
});
it('should catch and log an error if raw attack discoveries is null', async () => {
await updateAttackDiscoveries({
...mockArgs,
rawAttackDiscoveries: null,
});
expect(mockLogger.error).toHaveBeenCalledTimes(1);
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: 'tool returned no attack discoveries',
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => {
getAttackDiscovery.mockResolvedValue({
...mockCurrentAd,
status: attackDiscoveryStatus.canceled,
});
await updateAttackDiscoveries(mockArgs);
expect(mockLogger.error).not.toHaveBeenCalled();
expect(updateAttackDiscovery).not.toHaveBeenCalled();
});
it('should log the error and report telemetry when getAttackDiscovery rejects', async () => {
getAttackDiscovery.mockRejectedValue(mockError);
await updateAttackDiscoveries(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).not.toHaveBeenCalled();
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
});
describe('handleToolError', () => {
const mockArgs = {
apiConfig: mockApiConfig,
attackDiscoveryId: 'discovery-id',
authenticatedUser: mockAuthenticatedUser,
backingIndex: 'backing-index',
dataClient: mockDataClient,
err: mockError,
latestReplacements: {},
logger: mockLogger,
telemetry: mockTelemetry,
};
it('should log the error and update attack discovery status to failed', async () => {
await handleToolError(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
status: attackDiscoveryStatus.failed,
attackDiscoveries: [],
backingIndex: 'foo',
failureReason: 'Test error',
id: 'discovery-id',
replacements: {},
},
authenticatedUser: mockArgs.authenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', {
actionTypeId: mockApiConfig.actionTypeId,
alertsContextCount: 0,
alertsCount: 0,
configuredAlertsCount: mockSize,
discoveriesGenerated: 0,
durationMs: 120000,
model: mockApiConfig.model,
provider: mockApiConfig.provider,
it('should log the error and report telemetry when updateAttackDiscovery rejects', async () => {
updateAttackDiscovery.mockRejectedValue(mockError);
await handleToolError(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
status: attackDiscoveryStatus.failed,
attackDiscoveries: [],
backingIndex: 'foo',
failureReason: 'Test error',
id: 'discovery-id',
replacements: {},
},
authenticatedUser: mockArgs.authenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
});
it('should catch and log an error if raw attack discoveries is null', async () => {
await updateAttackDiscoveries({
...mockArgs,
rawAttackDiscoveries: null,
it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => {
getAttackDiscovery.mockResolvedValue({
...mockCurrentAd,
status: attackDiscoveryStatus.canceled,
});
await handleToolError(mockArgs);
expect(mockTelemetry.reportEvent).not.toHaveBeenCalled();
expect(updateAttackDiscovery).not.toHaveBeenCalled();
});
expect(mockLogger.error).toHaveBeenCalledTimes(1);
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: 'tool returned no attack discoveries',
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => {
getAttackDiscovery.mockResolvedValue({
...mockCurrentAd,
status: attackDiscoveryStatus.canceled,
});
await updateAttackDiscoveries(mockArgs);
it('should log the error and report telemetry when getAttackDiscovery rejects', async () => {
getAttackDiscovery.mockRejectedValue(mockError);
await handleToolError(mockArgs);
expect(mockLogger.error).not.toHaveBeenCalled();
expect(updateAttackDiscovery).not.toHaveBeenCalled();
});
it('should log the error and report telemetry when getAttackDiscovery rejects', async () => {
getAttackDiscovery.mockRejectedValue(mockError);
await updateAttackDiscoveries(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).not.toHaveBeenCalled();
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
});
describe('handleToolError', () => {
const mockArgs = {
apiConfig: mockApiConfig,
attackDiscoveryId: 'discovery-id',
authenticatedUser: mockAuthenticatedUser,
backingIndex: 'backing-index',
dataClient: mockDataClient,
err: mockError,
latestReplacements: {},
logger: mockLogger,
telemetry: mockTelemetry,
};
it('should log the error and update attack discovery status to failed', async () => {
await handleToolError(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
status: attackDiscoveryStatus.failed,
attackDiscoveries: [],
backingIndex: 'foo',
failureReason: 'Test error',
id: 'discovery-id',
replacements: {},
},
authenticatedUser: mockArgs.authenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
it('should log the error and report telemetry when updateAttackDiscovery rejects', async () => {
updateAttackDiscovery.mockRejectedValue(mockError);
await handleToolError(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
status: attackDiscoveryStatus.failed,
attackDiscoveries: [],
backingIndex: 'foo',
failureReason: 'Test error',
id: 'discovery-id',
replacements: {},
},
authenticatedUser: mockArgs.authenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => {
getAttackDiscovery.mockResolvedValue({
...mockCurrentAd,
status: attackDiscoveryStatus.canceled,
});
await handleToolError(mockArgs);
expect(mockTelemetry.reportEvent).not.toHaveBeenCalled();
expect(updateAttackDiscovery).not.toHaveBeenCalled();
});
it('should log the error and report telemetry when getAttackDiscovery rejects', async () => {
getAttackDiscovery.mockRejectedValue(mockError);
await handleToolError(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).not.toHaveBeenCalled();
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).not.toHaveBeenCalled();
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { AxisTick } from '.';
describe('AxisTick', () => {
it('renders the top cell', async () => {
const { getByTestId } = render(<AxisTick />);
const topCell = getByTestId('topCell');
expect(topCell).toBeInTheDocument();
});
it('renders the bottom cell', async () => {
const { getByTestId } = render(<AxisTick />);
const bottomCell = getByTestId('bottomCell');
expect(bottomCell).toBeInTheDocument();
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { getTacticMetadata } from '../../helpers';
import { AttackChain } from '.';
import { mockAttackDiscovery } from '../../mock/mock_attack_discovery';
describe('AttackChain', () => {
it('renders the expected tactics', () => {
// get detected tactics from the attack discovery:
const tacticMetadata = getTacticMetadata(mockAttackDiscovery).filter(
(tactic) => tactic.detected
);
expect(tacticMetadata.length).toBeGreaterThan(0); // test pre-condition
render(<AttackChain attackDiscovery={mockAttackDiscovery} />);
tacticMetadata?.forEach((tactic) => {
expect(screen.getByText(tactic.name)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { Tactic } from '.';
describe('Tactic', () => {
const tactic = 'Privilege Escalation';
it('renders the tactic name', () => {
render(<Tactic detected={false} tactic={tactic} />);
const tacticText = screen.getByTestId('tacticText');
expect(tacticText).toHaveTextContent(tactic);
});
const detectedTestCases: boolean[] = [true, false];
detectedTestCases.forEach((detected) => {
it(`renders the inner circle when detected is ${detected}`, () => {
render(<Tactic detected={detected} tactic={tactic} />);
const innerCircle = screen.getByTestId('innerCircle');
expect(innerCircle).toBeInTheDocument();
});
it(`renders the outerCircle circle when detected is ${detected}`, () => {
render(<Tactic detected={detected} tactic={tactic} />);
const outerCircle = screen.getByTestId('outerCircle');
expect(outerCircle).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,28 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import type { TacticMetadata } from '../../helpers';
import { getTacticMetadata } from '../../helpers';
import { mockAttackDiscovery } from '../../mock/mock_attack_discovery';
import { MiniAttackChain } from '.';
describe('MiniAttackChain', () => {
it('displays the expected number of circles', () => {
// get detected tactics from the attack discovery:
const tacticMetadata: TacticMetadata[] = getTacticMetadata(mockAttackDiscovery);
expect(tacticMetadata.length).toBeGreaterThan(0); // test pre-condition
render(<MiniAttackChain attackDiscovery={mockAttackDiscovery} />);
const circles = screen.getAllByTestId('circle');
expect(circles.length).toEqual(tacticMetadata.length);
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { getIconFromFieldName } from './helpers';
describe('helpers', () => {
describe('getIconFromFieldName', () => {
it('returns the expected icon for a known field name', () => {
const fieldName = 'host.name';
const expectedIcon = 'desktop';
const icon = getIconFromFieldName(fieldName);
expect(icon).toEqual(expectedIcon);
});
it('returns an empty string for an unknown field name', () => {
const fieldName = 'unknown.field';
const emptyIcon = '';
const icon = getIconFromFieldName(fieldName);
expect(icon).toEqual(emptyIcon);
});
});
});

View file

@ -0,0 +1,102 @@
/*
* 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 {
EuiMarkdownFormat,
getDefaultEuiMarkdownParsingPlugins,
getDefaultEuiMarkdownProcessingPlugins,
} from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../common/mock';
import { getFieldMarkdownRenderer } from '../field_markdown_renderer';
import { AttackDiscoveryMarkdownParser } from '.';
describe('AttackDiscoveryMarkdownParser', () => {
it('parsees markdown with valid fields', () => {
const attackDiscoveryParsingPluginList = [
...getDefaultEuiMarkdownParsingPlugins(),
AttackDiscoveryMarkdownParser,
];
const markdownWithValidFields = `
The following attack chain was detected involving Microsoft Office documents on multiple hosts:
- On {{ host.name 39054a91-67f9-46fa-b9d1-85f928d4cd1b }}, a malicious Microsoft Office document was opened by {{ user.name 2c13d131-8fab-41b9-841e-669c66315a23 }}.
- This document launched a child process to write and execute a malicious script file named "AppPool.vbs".
- The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1".
- On {{ host.name 5149b291-72d0-4373-93ec-c117477932fe }}, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented.
This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls.
`;
const processingPluginList = getDefaultEuiMarkdownProcessingPlugins();
processingPluginList[1][1].components.fieldPlugin = getFieldMarkdownRenderer(false);
render(
<TestProviders>
<EuiMarkdownFormat
color="subdued"
data-test-subj="attackDiscoveryMarkdownFormatter"
parsingPluginList={attackDiscoveryParsingPluginList}
processingPluginList={processingPluginList}
textSize="xs"
>
{markdownWithValidFields}
</EuiMarkdownFormat>
</TestProviders>
);
const result = screen.getByTestId('attackDiscoveryMarkdownFormatter');
expect(result).toHaveTextContent(
'The following attack chain was detected involving Microsoft Office documents on multiple hosts: On 39054a91-67f9-46fa-b9d1-85f928d4cd1b, a malicious Microsoft Office document was opened by 2c13d131-8fab-41b9-841e-669c66315a23. This document launched a child process to write and execute a malicious script file named "AppPool.vbs". The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". On 5149b291-72d0-4373-93ec-c117477932fe, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls.'
);
});
it('parsees markdown with invalid fields', () => {
const attackDiscoveryParsingPluginList = [
...getDefaultEuiMarkdownParsingPlugins(),
AttackDiscoveryMarkdownParser,
];
const markdownWithInvalidFields = `
The following attack chain was detected involving Microsoft Office documents on multiple hosts:
- On {{ host.name 39054a91-67f9-46fa-b9d1-85f928d4cd1b }}, a malicious Microsoft Office document was opened by {{ user.name }}.
- This document launched a child process to write and execute a malicious script file named "AppPool.vbs".
- The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1".
- On {{ 5149b291-72d0-4373-93ec-c117477932fe }}, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented.
This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls. {{ foo.bar baz }}
`;
const processingPluginList = getDefaultEuiMarkdownProcessingPlugins();
processingPluginList[1][1].components.fieldPlugin = getFieldMarkdownRenderer(false);
render(
<TestProviders>
<EuiMarkdownFormat
color="subdued"
data-test-subj="attackDiscoveryMarkdownFormatter"
parsingPluginList={attackDiscoveryParsingPluginList}
processingPluginList={processingPluginList}
textSize="xs"
>
{markdownWithInvalidFields}
</EuiMarkdownFormat>
</TestProviders>
);
const result = screen.getByTestId('attackDiscoveryMarkdownFormatter');
expect(result).toHaveTextContent(
'The following attack chain was detected involving Microsoft Office documents on multiple hosts: On 39054a91-67f9-46fa-b9d1-85f928d4cd1b, a malicious Microsoft Office document was opened by . This document launched a child process to write and execute a malicious script file named "AppPool.vbs". The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". On (Empty string), a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls. baz'
);
});
});

View file

@ -0,0 +1,44 @@
/*
* 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 { getHostFlyoutPanelProps, isHostName } from './get_host_flyout_panel_props';
describe('getHostFlyoutPanelProps', () => {
describe('isHostName', () => {
it('returns true for "host.name"', () => {
const result = isHostName('host.name');
expect(result).toBe(true);
});
it('returns true for "host.hostname"', () => {
const result = isHostName('host.hostname');
expect(result).toBe(true);
});
it('returns false for other field names', () => {
const result = isHostName('some.other.field');
expect(result).toBe(false);
});
});
describe('getHostFlyoutPanelProps', () => {
it('returns the correct FlyoutPanelProps', () => {
const contextId = 'contextId';
const hostName = 'foo';
const result = getHostFlyoutPanelProps({ contextId, hostName });
expect(result).toEqual({
id: 'host-panel',
params: { contextID: contextId, hostName, scopeId: 'alerts-page' },
});
});
});
});

View file

@ -0,0 +1,26 @@
/*
* 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 { isUserName } from './get_user_flyout_panel_props';
describe('getUserFlyoutPanelProps', () => {
describe('isUserName', () => {
it('returns true when fieldName is "user.name"', () => {
const fieldName = 'user.name';
const result = isUserName(fieldName);
expect(result).toBe(true);
});
it('returns false when fieldName is NOT "user.name"', () => {
const fieldName = 'other.field';
const result = isUserName(fieldName);
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,58 @@
/*
* 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 { getFlyoutPanelProps } from './helpers';
describe('helpers', () => {
describe('getFlyoutPanelProps', () => {
it('returns FlyoutPanelProps for a valid host name', () => {
const contextId = 'contextId';
const fieldName = 'host.name';
const value = 'example.com';
const flyoutPanelProps = getFlyoutPanelProps({ contextId, fieldName, value });
expect(flyoutPanelProps).toEqual({
id: 'host-panel',
params: { contextID: contextId, hostName: value, scopeId: 'alerts-page' },
});
});
it('returns FlyoutPanelProps for a valid user name', () => {
const contextId = 'contextId';
const fieldName = 'user.name';
const value = 'administator';
const flyoutPanelProps = getFlyoutPanelProps({ contextId, fieldName, value });
expect(flyoutPanelProps).toEqual({
id: 'user-panel',
params: { contextID: contextId, userName: value, scopeId: 'alerts-page' },
});
});
it('returns null for an unknown field name', () => {
const contextId = 'contextId';
const fieldName = 'unknown.field';
const value = 'example';
const flyoutPanelProps = getFlyoutPanelProps({ contextId, fieldName, value });
expect(flyoutPanelProps).toBeNull();
});
it('returns null when value is not a string', () => {
const contextId = 'contextId';
const fieldName = 'host.name';
const value = 123;
const flyoutPanelProps = getFlyoutPanelProps({ contextId, fieldName, value });
expect(flyoutPanelProps).toBeNull();
});
});
});

View file

@ -0,0 +1,110 @@
/*
* 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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../common/mock';
import { getFieldMarkdownRenderer } from '.';
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
}));
describe('getFieldMarkdownRenderer', () => {
const mockOpenRightPanel = jest.fn();
const mockUseExpandableFlyoutApi = useExpandableFlyoutApi as jest.MockedFunction<
typeof useExpandableFlyoutApi
>;
beforeEach(() => {
jest.clearAllMocks();
mockUseExpandableFlyoutApi.mockReturnValue({
closeFlyout: jest.fn(),
closeLeftPanel: jest.fn(),
closePreviewPanel: jest.fn(),
closeRightPanel: jest.fn(),
previousPreviewPanel: jest.fn(),
openFlyout: jest.fn(),
openLeftPanel: jest.fn(),
openPreviewPanel: jest.fn(),
openRightPanel: mockOpenRightPanel,
});
});
it('renders the field value', () => {
const FieldMarkdownRenderer = getFieldMarkdownRenderer(false);
const icon = '';
const name = 'some.field';
const value = 'some.value';
render(
<TestProviders>
<FieldMarkdownRenderer icon={icon} name={name} operator={':'} value={value} />
</TestProviders>
);
const fieldValue = screen.getByText(value);
expect(fieldValue).toBeInTheDocument();
});
it('opens the right panel when the entity button is clicked', () => {
const FieldMarkdownRenderer = getFieldMarkdownRenderer(false);
const icon = 'user';
const name = 'user.name';
const value = 'some.user';
render(
<TestProviders>
<FieldMarkdownRenderer icon={icon} name={name} operator={':'} value={value} />
</TestProviders>
);
const entityButton = screen.getByTestId('entityButton');
fireEvent.click(entityButton);
expect(mockOpenRightPanel).toHaveBeenCalledTimes(1);
});
it('does NOT render the entity button when flyoutPanelProps is null', () => {
const FieldMarkdownRenderer = getFieldMarkdownRenderer(false);
const icon = '';
const name = 'some.field';
const value = 'some.value';
render(
<TestProviders>
<FieldMarkdownRenderer icon={icon} name={name} operator={':'} value={value} />
</TestProviders>
);
const entityButton = screen.queryByTestId('entityButton');
expect(entityButton).not.toBeInTheDocument();
});
it('renders disabled actions badge when disableActions is true', () => {
const FieldMarkdownRenderer = getFieldMarkdownRenderer(true); // disable actions
const icon = 'user';
const name = 'user.name';
const value = 'some.user';
render(
<TestProviders>
<FieldMarkdownRenderer icon={icon} name={name} operator={':'} value={value} />
</TestProviders>
);
const disabledActionsBadge = screen.getByTestId('disabledActionsBadge');
expect(disabledActionsBadge).toBeInTheDocument();
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../common/mock';
import { AttackDiscoveryMarkdownFormatter } from '.';
describe('AttackDiscoveryMarkdownFormatter', () => {
const markdown = `
The following attack chain was detected involving Microsoft Office documents on multiple hosts:
- On {{ host.name 39054a91-67f9-46fa-b9d1-85f928d4cd1b }}, a malicious Microsoft Office document was opened by {{ user.name 2c13d131-8fab-41b9-841e-669c66315a23 }}.
- This document launched a child process to write and execute a malicious script file named "AppPool.vbs".
- The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1".
- On {{ host.name 5149b291-72d0-4373-93ec-c117477932fe }}, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented.
This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls.
`;
it('renders the expected markdown', () => {
render(
<TestProviders>
<AttackDiscoveryMarkdownFormatter markdown={markdown} />
</TestProviders>
);
const result = screen.getByTestId('attackDiscoveryMarkdownFormatter');
expect(result).toHaveTextContent(
'The following attack chain was detected involving Microsoft Office documents on multiple hosts: On 39054a91-67f9-46fa-b9d1-85f928d4cd1b, a malicious Microsoft Office document was opened by 2c13d131-8fab-41b9-841e-669c66315a23. This document launched a child process to write and execute a malicious script file named "AppPool.vbs". The "AppPool.vbs" script then spawned PowerShell to execute an obfuscated script payload from "AppPool.ps1". On 5149b291-72d0-4373-93ec-c117477932fe, a similar attack involving a malicious Office document and the creation of "AppPool.vbs" was detected and prevented. This appears to be a malware attack delivered via spearphishing, likely exploiting a vulnerability in Microsoft Office to gain initial access and then using PowerShell for execution and obfuscation. The attacker employed defense evasion techniques like script obfuscation and system binary proxies like "wscript.exe" and "mshta.exe". Mitigations should focus on patching Office vulnerabilities, restricting script execution, and enhancing email security controls.'
);
});
it('renders interactive host entities', () => {
render(
<TestProviders>
<AttackDiscoveryMarkdownFormatter markdown={markdown} />
</TestProviders>
);
const entities = screen.getAllByTestId('entityButton');
expect(entities.length).toEqual(3);
});
it('renders NON-interactive host entities when disableActions is true', () => {
render(
<TestProviders>
<AttackDiscoveryMarkdownFormatter disableActions={true} markdown={markdown} />
</TestProviders>
);
const entities = screen.queryAllByTestId('entityButton');
expect(entities.length).toEqual(0); // <-- no interactive buttons
});
});

View file

@ -0,0 +1,109 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { ActionableSummary } from '.';
import { TestProviders } from '../../../common/mock';
import { mockAttackDiscovery } from '../../mock/mock_attack_discovery';
describe('ActionableSummary', () => {
const mockReplacements = {
'5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname',
'3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username',
};
describe('when entities with replacements are provided', () => {
beforeEach(() => {
render(
<TestProviders>
<ActionableSummary
attackDiscovery={mockAttackDiscovery}
replacements={mockReplacements}
/>
</TestProviders>
);
});
it('renders a hostname with the expected value from replacements', () => {
expect(screen.getAllByTestId('entityButton')[0]).toHaveTextContent('foo.hostname');
});
it('renders a username with the expected value from replacements', () => {
expect(screen.getAllByTestId('entityButton')[1]).toHaveTextContent('bar.username');
});
});
describe('when entities that do NOT have replacements are provided', () => {
beforeEach(() => {
render(
<TestProviders>
<ActionableSummary
attackDiscovery={mockAttackDiscovery}
replacements={{}} // <-- no replacements for the entities
/>
</TestProviders>
);
});
it('renders a hostname with with the original hostname value', () => {
expect(screen.getAllByTestId('entityButton')[0]).toHaveTextContent(
'5e454c38-439c-4096-8478-0a55511c76e3'
);
});
it('renders a username with the original username value', () => {
expect(screen.getAllByTestId('entityButton')[1]).toHaveTextContent(
'3bdc7952-a334-4d95-8092-cd176546e18a'
);
});
});
describe('when showAnonymized is true', () => {
beforeEach(() => {
render(
<TestProviders>
<ActionableSummary
attackDiscovery={mockAttackDiscovery}
replacements={mockReplacements}
showAnonymized={true} // <-- show anonymized entities
/>
</TestProviders>
);
});
it('renders a disabled badge with the original hostname value', () => {
expect(screen.getAllByTestId('disabledActionsBadge')[0]).toHaveTextContent(
'5e454c38-439c-4096-8478-0a55511c76e3'
);
});
it('renders a disabled badge with the original username value', () => {
expect(screen.getAllByTestId('disabledActionsBadge')[1]).toHaveTextContent(
'3bdc7952-a334-4d95-8092-cd176546e18a'
);
});
});
describe('View in AI assistant', () => {
beforeEach(() => {
render(
<TestProviders>
<ActionableSummary
attackDiscovery={mockAttackDiscovery}
replacements={mockReplacements}
/>
</TestProviders>
);
});
it('renders the View in AI assistant button', () => {
expect(screen.getByTestId('viewInAiAssistantCompact')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,23 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { ActionsPlaceholder } from '.';
describe('ActionsPlaceholder', () => {
beforeEach(() => render(<ActionsPlaceholder />));
const expectedSkeletonTitles = ['skeletonTitle1', 'skeletonTitle2', 'skeletonTitle3'];
expectedSkeletonTitles.forEach((expectedSkeletonTitle) => {
it(`renders the ${expectedSkeletonTitle} skeleton title`, () => {
expect(screen.getByTestId(expectedSkeletonTitle)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { AlertsBadge } from '.';
describe('AlertsBadge', () => {
it('render the expected alerts count', () => {
const alertsCount = 5;
const { getByTestId } = render(<AlertsBadge alertsCount={alertsCount} />);
const badgeElement = getByTestId('alertsBadge');
expect(badgeElement.textContent).toBe(`${alertsCount}`);
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { Actions } from '.';
import { TestProviders } from '../../../common/mock';
import { mockAttackDiscovery } from '../../mock/mock_attack_discovery';
import { ATTACK_CHAIN, ALERTS } from './translations';
describe('Actions', () => {
beforeEach(() =>
render(
<TestProviders>
<Actions attackDiscovery={mockAttackDiscovery} />
</TestProviders>
)
);
it('renders the attack chain label', () => {
expect(screen.getByTestId('attackChainLabel')).toHaveTextContent(ATTACK_CHAIN);
});
it('renders the mini attack chain component', () => {
expect(screen.getByTestId('miniAttackChain')).toBeInTheDocument();
});
it('renders the alerts label', () => {
expect(screen.getByTestId('alertsLabel')).toHaveTextContent(ALERTS);
});
it('renders the alerts badge with the expected count', () => {
expect(screen.getByTestId('alertsBadge')).toHaveTextContent(
`${mockAttackDiscovery.alertIds.length}`
);
});
it('renders the take action dropdown', () => {
expect(screen.getByTestId('takeAction')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { getOriginalAlertIds } from './helpers';
describe('helpers', () => {
describe('getOriginalAlertIds', () => {
const alertIds = ['alert1', 'alert2', 'alert3'];
it('returns the original alertIds when no replacements are provided', () => {
const result = getOriginalAlertIds({ alertIds });
expect(result).toEqual(alertIds);
});
it('returns the replaced alertIds when replacements are provided', () => {
const replacements = {
alert1: 'replaced1',
alert3: 'replaced3',
};
const expected = ['replaced1', 'alert2', 'replaced3'];
const result = getOriginalAlertIds({ alertIds, replacements });
expect(result).toEqual(expected);
});
it('returns the original alertIds when replacements are provided but no replacement is found', () => {
const replacements = {
alert4: 'replaced4',
alert5: 'replaced5',
};
const result = getOriginalAlertIds({ alertIds, replacements });
expect(result).toEqual(alertIds);
});
});
});

View file

@ -0,0 +1,47 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery';
import { TakeAction } from '.';
describe('TakeAction', () => {
beforeEach(() => {
jest.clearAllMocks();
render(
<TestProviders>
<TakeAction attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
const takeActionButtons = screen.getAllByTestId('takeActionPopoverButton');
fireEvent.click(takeActionButtons[0]); // open the popover
});
it('renders the Add to new case action', () => {
const addToCase = screen.getByTestId('addToCase');
expect(addToCase).toBeInTheDocument();
});
it('renders the Add to existing case action', () => {
const addToCase = screen.getByTestId('addToExistingCase');
expect(addToCase).toBeInTheDocument();
});
it('renders the View in AI Assistant action', () => {
const addToCase = screen.getByTestId('viewInAiAssistant');
expect(addToCase).toBeInTheDocument();
});
});

View file

@ -0,0 +1,87 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { useAddToNewCase } from '.';
import { TestProviders } from '../../../../common/mock';
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: jest.fn().mockReturnValue({
services: {
cases: {
hooks: {
useCasesAddToNewCaseFlyout: jest.fn().mockReturnValue({
open: jest.fn(),
}),
},
},
},
}),
}));
describe('useAddToNewCase', () => {
it('disables the action when a user can NOT create and read cases', () => {
const canUserCreateAndReadCases = jest.fn().mockReturnValue(false);
const { result } = renderHook(
() =>
useAddToNewCase({
canUserCreateAndReadCases,
title: 'Persistent Execution of Malicious Application',
}),
{
wrapper: TestProviders,
}
);
expect(result.current.disabled).toBe(true);
});
it('enables the action when a user can create and read cases', () => {
const canUserCreateAndReadCases = jest.fn().mockReturnValue(true);
const { result } = renderHook(
() =>
useAddToNewCase({
canUserCreateAndReadCases,
title: 'Persistent Execution of Malicious Application',
}),
{
wrapper: TestProviders,
}
);
expect(result.current.disabled).toBe(false);
});
it('calls the onClick callback when provided', () => {
const onClick = jest.fn();
const canUserCreateAndReadCases = jest.fn().mockReturnValue(true);
const { result } = renderHook(
() =>
useAddToNewCase({
canUserCreateAndReadCases,
title: 'Persistent Execution of Malicious Application',
onClick,
}),
{
wrapper: TestProviders,
}
);
act(() => {
result.current.onAddToNewCase({
alertIds: ['alert1', 'alert2'],
markdownComments: ['Comment 1', 'Comment 2'],
});
});
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,142 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { useAddToExistingCase } from '.';
import { useKibana } from '../../../../common/lib/kibana';
import { TestProviders } from '../../../../common/mock';
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: jest.fn().mockReturnValue({
services: {
cases: {
hooks: {
useCasesAddToExistingCaseModal: jest.fn().mockReturnValue({
open: jest.fn(),
}),
},
},
},
}),
}));
describe('useAddToExistingCase', () => {
const mockCanUserCreateAndReadCases = jest.fn();
const mockOnClick = jest.fn();
const mockAlertIds = ['alert1', 'alert2'];
const mockMarkdownComments = ['Comment 1', 'Comment 2'];
const mockReplacements = { alert1: 'replacement1', alert2: 'replacement2' };
beforeEach(() => {
jest.clearAllMocks();
});
it('disables the action when a user can NOT create and read cases', () => {
mockCanUserCreateAndReadCases.mockReturnValue(false);
const { result } = renderHook(
() =>
useAddToExistingCase({
canUserCreateAndReadCases: mockCanUserCreateAndReadCases,
onClick: mockOnClick,
}),
{
wrapper: TestProviders,
}
);
expect(result.current.disabled).toBe(true);
});
it('enables the action when a user can create and read cases', () => {
mockCanUserCreateAndReadCases.mockReturnValue(true);
const { result } = renderHook(
() =>
useAddToExistingCase({
canUserCreateAndReadCases: mockCanUserCreateAndReadCases,
onClick: mockOnClick,
}),
{
wrapper: TestProviders,
}
);
expect(result.current.disabled).toBe(false);
});
it('calls the openSelectCaseModal function with the expected attachments', () => {
mockCanUserCreateAndReadCases.mockReturnValue(true);
const mockOpenSelectCaseModal = jest.fn();
(useKibana as jest.Mock).mockReturnValue({
services: {
cases: {
hooks: {
useCasesAddToExistingCaseModal: jest.fn().mockReturnValue({
open: mockOpenSelectCaseModal,
}),
},
},
},
});
const { result } = renderHook(
() =>
useAddToExistingCase({
canUserCreateAndReadCases: mockCanUserCreateAndReadCases,
onClick: mockOnClick,
}),
{
wrapper: TestProviders,
}
);
act(() => {
result.current.onAddToExistingCase({
alertIds: mockAlertIds,
markdownComments: mockMarkdownComments,
replacements: mockReplacements,
});
});
expect(mockOpenSelectCaseModal).toHaveBeenCalledWith({
getAttachments: expect.any(Function),
});
const getAttachments = mockOpenSelectCaseModal.mock.calls[0][0].getAttachments;
const attachments = getAttachments();
expect(attachments).toHaveLength(4);
expect(attachments[0]).toEqual({
comment: 'Comment 1',
type: 'user',
});
expect(attachments[1]).toEqual({
comment: 'Comment 2',
type: 'user',
});
expect(attachments[2]).toEqual({
alertId: 'replacement1', // <-- case attachment uses the replacement values
index: '',
rule: {
id: null,
name: null,
},
type: 'alert',
});
expect(attachments[3]).toEqual({
alertId: 'replacement2',
index: '',
rule: {
id: null,
name: null,
},
type: 'alert',
});
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { AttackDiscoveryPanel } from '.';
import { TestProviders } from '../../common/mock';
import { mockAttackDiscovery } from '../mock/mock_attack_discovery';
describe('AttackDiscoveryPanel', () => {
it('renders the attack discovery accordion', () => {
render(
<TestProviders>
<AttackDiscoveryPanel attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
const attackDiscoveryAccordion = screen.getByTestId('attackDiscoveryAccordion');
expect(attackDiscoveryAccordion).toBeInTheDocument();
});
it('renders empty accordion content', () => {
render(
<TestProviders>
<AttackDiscoveryPanel attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
const emptyAccordionContent = screen.getByTestId('emptyAccordionContent');
expect(emptyAccordionContent).toBeInTheDocument();
});
it('renders the attack discovery summary', () => {
render(
<TestProviders>
<AttackDiscoveryPanel attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
const actionableSummary = screen.getByTestId('actionableSummary');
expect(actionableSummary).toBeInTheDocument();
});
it('renders the attack discovery tabs panel when accordion is open', () => {
render(
<TestProviders>
<AttackDiscoveryPanel attackDiscovery={mockAttackDiscovery} initialIsOpen={true} />
</TestProviders>
);
const attackDiscoveryTabsPanel = screen.getByTestId('attackDiscoveryTabsPanel');
expect(attackDiscoveryTabsPanel).toBeInTheDocument();
});
});

View file

@ -0,0 +1,27 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery';
import { AlertsTab } from '.';
describe('AlertsTab', () => {
it('renders the alerts tab', () => {
render(
<TestProviders>
<AlertsTab attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
const alertsTab = screen.getByTestId('alertsTab');
expect(alertsTab).toBeInTheDocument();
});
});

View file

@ -0,0 +1,139 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { AttackDiscoveryTab } from '.';
import type { Replacements } from '@kbn/elastic-assistant-common';
import { TestProviders } from '../../../../common/mock';
import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery';
import { ATTACK_CHAIN, DETAILS, SUMMARY } from './translations';
describe('AttackDiscoveryTab', () => {
const mockReplacements: Replacements = {
'5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname',
'3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username',
};
describe('when showAnonymized is false', () => {
const showAnonymized = false;
beforeEach(() =>
render(
<TestProviders>
<AttackDiscoveryTab
attackDiscovery={mockAttackDiscovery}
replacements={mockReplacements}
showAnonymized={showAnonymized}
/>
</TestProviders>
)
);
it('renders the summary using the real host and username', () => {
const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter');
const summaryMarkdown = markdownFormatters[0];
expect(summaryMarkdown).toHaveTextContent(
'A multi-stage malware attack was detected on foo.hostname involving bar.username. A suspicious application delivered malware, attempted credential theft, and established persistence.'
);
});
it('renders the details using the real host and username', () => {
const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter');
const detailsMarkdown = markdownFormatters[1];
expect(detailsMarkdown).toHaveTextContent(
`The following attack progression appears to have occurred on the host foo.hostname involving the user bar.username: A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation. This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable. The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials. The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing. This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.`
);
});
});
describe('when showAnonymized is true', () => {
const showAnonymized = true;
beforeEach(() =>
render(
<TestProviders>
<AttackDiscoveryTab
attackDiscovery={mockAttackDiscovery}
replacements={mockReplacements}
showAnonymized={showAnonymized}
/>
</TestProviders>
)
);
it('renders the summary using the anonymized host and username', () => {
const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter');
const summaryMarkdown = markdownFormatters[0];
expect(summaryMarkdown).toHaveTextContent(
'A multi-stage malware attack was detected on 5e454c38-439c-4096-8478-0a55511c76e3 involving 3bdc7952-a334-4d95-8092-cd176546e18a. A suspicious application delivered malware, attempted credential theft, and established persistence.'
);
});
it('renders the details using the anonymized host and username', () => {
const markdownFormatters = screen.getAllByTestId('attackDiscoveryMarkdownFormatter');
const detailsMarkdown = markdownFormatters[1];
expect(detailsMarkdown).toHaveTextContent(
`The following attack progression appears to have occurred on the host 5e454c38-439c-4096-8478-0a55511c76e3 involving the user 3bdc7952-a334-4d95-8092-cd176546e18a: A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation. This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable. The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials. The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing. This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.`
);
});
});
describe('common cases', () => {
beforeEach(() =>
render(
<TestProviders>
<AttackDiscoveryTab
attackDiscovery={mockAttackDiscovery}
replacements={mockReplacements}
/>
</TestProviders>
)
);
it('renders the expected summary title', () => {
const summaryTitle = screen.getByTestId('summaryTitle');
expect(summaryTitle).toHaveTextContent(SUMMARY);
});
it('renders the expected details title', () => {
const detailsTitle = screen.getByTestId('detailsTitle');
expect(detailsTitle).toHaveTextContent(DETAILS);
});
it('renders the expected attack chain title', () => {
const attackChainTitle = screen.getByTestId('attackChainTitle');
expect(attackChainTitle).toHaveTextContent(ATTACK_CHAIN);
});
it('renders the attack chain', () => {
const attackChain = screen.getByTestId('attackChain');
expect(attackChain).toBeInTheDocument();
});
it('renders the "View in AI Assistant" button', () => {
const viewInAiAssistantButton = screen.getByTestId('viewInAiAssistant');
expect(viewInAiAssistantButton).toBeInTheDocument();
});
it('renders the "Investigate in Timeline" button', () => {
const investigateInTimelineButton = screen.getByTestId('investigateInTimelineButton');
expect(investigateInTimelineButton).toBeInTheDocument();
});
});
});

View file

@ -85,7 +85,7 @@ const AttackDiscoveryTabComponent: React.FC<Props> = ({
{tacticMetadata.length > 0 && (
<>
<EuiTitle data-test-subj="detailsTitle" size="xs">
<EuiTitle data-test-subj="attackChainTitle" size="xs">
<h2>{i18n.ATTACK_CHAIN}</h2>
</EuiTitle>
<EuiSpacer size="s" />

View file

@ -0,0 +1,63 @@
/*
* 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 type { Replacements } from '@kbn/elastic-assistant-common';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { getTabs } from './get_tabs';
import { TestProviders } from '../../../common/mock';
import { mockAttackDiscovery } from '../../mock/mock_attack_discovery';
import { ALERTS, ATTACK_DISCOVERY } from './translations';
describe('getTabs', () => {
const mockReplacements: Replacements = {
'5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname',
'3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username',
};
const tabs = getTabs({
attackDiscovery: mockAttackDiscovery,
replacements: mockReplacements,
});
describe('Attack discovery tab', () => {
const attackDiscoveryTab = tabs.find((tab) => tab.id === 'attackDiscovery--id');
it('includes the Attack discovery tab', () => {
expect(attackDiscoveryTab).not.toBeUndefined();
});
it('has the expected tab name', () => {
expect(attackDiscoveryTab?.name).toEqual(ATTACK_DISCOVERY);
});
it('renders the expected content', () => {
render(<TestProviders>{attackDiscoveryTab?.content}</TestProviders>);
expect(screen.getByTestId('attackDiscoveryTab')).toBeInTheDocument();
});
});
describe('Alerts tab', () => {
const alertsTab = tabs.find((tab) => tab.id === 'alerts--id');
it('includes the Alerts tab', () => {
expect(alertsTab).not.toBeUndefined();
});
it('has the expected tab name', () => {
expect(alertsTab?.name).toEqual(ALERTS);
});
it('renders the expected content', () => {
render(<TestProviders>{alertsTab?.content}</TestProviders>);
expect(screen.getByTestId('alertsTab')).toBeInTheDocument();
});
});
});

View file

@ -13,7 +13,7 @@ import { AttackDiscoveryTab } from './attack_discovery_tab';
import { AlertsTab } from './alerts_tab';
import * as i18n from './translations';
interface TabInfo {
export interface TabInfo {
content: JSX.Element;
id: string;
name: string;

View file

@ -0,0 +1,38 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { Tabs } from '.';
import { TestProviders } from '../../../common/mock';
import { mockAttackDiscovery } from '../../mock/mock_attack_discovery';
describe('Tabs', () => {
beforeEach(() => {
render(
<TestProviders>
<Tabs attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
});
it('renders the attack discovery tab', () => {
const attackDiscoveryTab = screen.getByTestId('attackDiscoveryTab');
expect(attackDiscoveryTab).toBeInTheDocument();
});
it("renders the alerts tab when it's selected", () => {
const alertsTabButton = screen.getByText('Alerts');
fireEvent.click(alertsTabButton);
const alertsTab = screen.getByTestId('alertsTab');
expect(alertsTab).toBeInTheDocument();
});
});

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 { render, screen } from '@testing-library/react';
import React from 'react';
import { Title } from '.';
describe('Title', () => {
const title = 'Malware Delivery and Credentials Access on macOS';
it('renders the assistant avatar', () => {
render(<Title isLoading={false} title={title} />);
const assistantAvatar = screen.getByTestId('assistantAvatar');
expect(assistantAvatar).toBeInTheDocument();
});
it('renders the expected title', () => {
render(<Title isLoading={false} title={title} />);
const titleText = screen.getByTestId('titleText');
expect(titleText).toHaveTextContent(title);
});
it('renders the skeleton title when isLoading is true', () => {
render(<Title isLoading={true} title={title} />);
const skeletonTitle = screen.getByTestId('skeletonTitle');
expect(skeletonTitle).toBeInTheDocument();
});
});

View file

@ -0,0 +1,66 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { ViewInAiAssistant } from '.';
import { TestProviders } from '../../../common/mock';
import { mockAttackDiscovery } from '../../mock/mock_attack_discovery';
import { VIEW_IN_AI_ASSISTANT } from './translations';
describe('ViewInAiAssistant', () => {
it('renders the assistant avatar', () => {
render(
<TestProviders>
<ViewInAiAssistant attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
const assistantAvatar = screen.getByTestId('assistantAvatar');
expect(assistantAvatar).toBeInTheDocument();
});
it('renders the expected button label', () => {
render(
<TestProviders>
<ViewInAiAssistant attackDiscovery={mockAttackDiscovery} />
</TestProviders>
);
const viewInAiAssistantLabel = screen.getByTestId('viewInAiAssistantLabel');
expect(viewInAiAssistantLabel).toHaveTextContent(VIEW_IN_AI_ASSISTANT);
});
describe('compact mode', () => {
it('does NOT render the assistant avatar', () => {
render(
<TestProviders>
<ViewInAiAssistant attackDiscovery={mockAttackDiscovery} compact={true} />
</TestProviders>
);
const assistantAvatar = screen.queryByTestId('assistantAvatar');
expect(assistantAvatar).not.toBeInTheDocument();
});
it('renders the expected button text', () => {
render(
<TestProviders>
<ViewInAiAssistant attackDiscovery={mockAttackDiscovery} compact={true} />
</TestProviders>
);
const viewInAiAssistantCompact = screen.getByTestId('viewInAiAssistantCompact');
expect(viewInAiAssistantCompact).toHaveTextContent(VIEW_IN_AI_ASSISTANT);
});
});
});

View file

@ -0,0 +1,86 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useAssistantOverlay } from '@kbn/elastic-assistant';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
import { getAttackDiscoveryMarkdown } from '../../get_attack_discovery_markdown/get_attack_discovery_markdown';
import { mockAttackDiscovery } from '../../mock/mock_attack_discovery';
import { useViewInAiAssistant } from './use_view_in_ai_assistant';
jest.mock('@kbn/elastic-assistant');
jest.mock('../../../assistant/use_assistant_availability');
jest.mock('../../get_attack_discovery_markdown/get_attack_discovery_markdown');
describe('useViewInAiAssistant', () => {
beforeEach(() => {
jest.clearAllMocks();
(useAssistantOverlay as jest.Mock).mockReturnValue({
promptContextId: 'prompt-context-id',
showAssistantOverlay: jest.fn(),
});
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: true,
isAssistantEnabled: true,
});
(getAttackDiscoveryMarkdown as jest.Mock).mockResolvedValue('Test markdown');
});
it('returns the expected promptContextId', () => {
const { result } = renderHook(() =>
useViewInAiAssistant({
attackDiscovery: mockAttackDiscovery,
})
);
expect(result.current.promptContextId).toBe('prompt-context-id');
});
it('returns disabled: false when the user has assistant privileges and promptContextId is provided', () => {
const { result } = renderHook(() =>
useViewInAiAssistant({
attackDiscovery: mockAttackDiscovery,
})
);
expect(result.current.disabled).toBe(false);
});
it('returns disabled: true when the user does NOT have assistant privileges', () => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: false, // <-- the user does NOT have assistant privileges
isAssistantEnabled: true,
});
const { result } = renderHook(() =>
useViewInAiAssistant({
attackDiscovery: mockAttackDiscovery,
})
);
expect(result.current.disabled).toBe(true);
});
it('returns disabled: true when promptContextId is null', () => {
(useAssistantOverlay as jest.Mock).mockReturnValue({
promptContextId: null, // <-- promptContextId is null
showAssistantOverlay: jest.fn(),
});
const { result } = renderHook(() =>
useViewInAiAssistant({
attackDiscovery: mockAttackDiscovery,
})
);
expect(result.current.disabled).toBe(true);
});
});

View file

@ -0,0 +1,188 @@
/*
* 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 {
getAttackChainMarkdown,
getAttackDiscoveryMarkdown,
getMarkdownFields,
getMarkdownWithOriginalValues,
} from './get_attack_discovery_markdown';
import { mockAttackDiscovery } from '../mock/mock_attack_discovery';
describe('getAttackDiscoveryMarkdown', () => {
describe('getMarkdownFields', () => {
it('replaces markdown fields with formatted values', () => {
const markdown = 'This is a {{ field1 value1 }} and {{ field2 value2 }}.';
const expected = 'This is a `value1` and `value2`.';
const result = getMarkdownFields(markdown);
expect(result).toBe(expected);
});
it('handles multiple occurrences of markdown fields', () => {
const markdown =
'This is a {{ field1 value1 }} and {{ field2 value2 }}. Also, {{ field1 value3 }}.';
const expected = 'This is a `value1` and `value2`. Also, `value3`.';
const result = getMarkdownFields(markdown);
expect(result).toBe(expected);
});
it('handles markdown fields with no spaces around them', () => {
const markdown = 'This is a {{field1 value1}} and {{field2 value2}}.';
const expected = 'This is a `value1` and `value2`.';
const result = getMarkdownFields(markdown);
expect(result).toBe(expected);
});
it('handles empty markdown', () => {
const markdown = '';
const expected = '';
const result = getMarkdownFields(markdown);
expect(result).toBe(expected);
});
});
describe('getAttackChainMarkdown', () => {
it('returns an empty string when no tactics are detected', () => {
const noTactics = {
...mockAttackDiscovery,
mitreAttackTactics: [],
};
const result = getAttackChainMarkdown(noTactics);
expect(result).toBe('');
});
it('returns the expected attack chain markdown when tactics are detected', () => {
const result = getAttackChainMarkdown(mockAttackDiscovery);
expect(result).toBe(`### Attack Chain
- Initial Access
- Execution
- Persistence
- Privilege Escalation
`);
});
});
describe('getMarkdownWithOriginalValues', () => {
const markdown = mockAttackDiscovery.summaryMarkdown;
it('returns the same markdown when no replacements are provided', () => {
const result = getMarkdownWithOriginalValues({ markdown });
expect(result).toBe(markdown);
});
it('replaces the UUIDs with the original values when replacements are provided ', () => {
const replacements = {
'5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname',
'3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username',
};
const expected =
'A multi-stage malware attack was detected on {{ host.name foo.hostname }} involving {{ user.name bar.username }}. A suspicious application delivered malware, attempted credential theft, and established persistence.';
const result = getMarkdownWithOriginalValues({ markdown, replacements });
expect(result).toBe(expected);
});
it('only replaces values when there are corresponding entries in the replacements', () => {
// The UUID '3bdc7952-a334-4d95-8092-cd176546e18a' is not in the replacements:
const replacements = {
'5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname',
};
const expected =
'A multi-stage malware attack was detected on {{ host.name foo.hostname }} involving {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}. A suspicious application delivered malware, attempted credential theft, and established persistence.';
const result = getMarkdownWithOriginalValues({ markdown, replacements });
expect(result).toBe(expected);
});
});
describe('getAttackDiscoveryMarkdown', () => {
it('returns the expected markdown when replacements are NOT provided', () => {
const expectedMarkdown = `## Malware Attack With Credential Theft Attempt
Suspicious activity involving the host \`5e454c38-439c-4096-8478-0a55511c76e3\` and user \`3bdc7952-a334-4d95-8092-cd176546e18a\`.
### Summary
A multi-stage malware attack was detected on \`5e454c38-439c-4096-8478-0a55511c76e3\` involving \`3bdc7952-a334-4d95-8092-cd176546e18a\`. A suspicious application delivered malware, attempted credential theft, and established persistence.
### Details
The following attack progression appears to have occurred on the host \`5e454c38-439c-4096-8478-0a55511c76e3\` involving the user \`3bdc7952-a334-4d95-8092-cd176546e18a\`:
- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation.
- This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable.
- The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials.
- The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing.
This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.
### Attack Chain
- Initial Access
- Execution
- Persistence
- Privilege Escalation
`;
const markdown = getAttackDiscoveryMarkdown({ attackDiscovery: mockAttackDiscovery });
expect(markdown).toBe(expectedMarkdown);
});
it('returns the expected markdown when replacements are provided', () => {
const replacements = {
'5e454c38-439c-4096-8478-0a55511c76e3': 'foo.hostname',
'3bdc7952-a334-4d95-8092-cd176546e18a': 'bar.username',
};
const expectedMarkdown = `## Malware Attack With Credential Theft Attempt
Suspicious activity involving the host \`foo.hostname\` and user \`bar.username\`.
### Summary
A multi-stage malware attack was detected on \`foo.hostname\` involving \`bar.username\`. A suspicious application delivered malware, attempted credential theft, and established persistence.
### Details
The following attack progression appears to have occurred on the host \`foo.hostname\` involving the user \`bar.username\`:
- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation.
- This application spawned child processes to copy a malicious file named "unix1" to the user's home directory and make it executable.
- The malicious "unix1" file was then executed, attempting to access the user's login keychain and potentially exfiltrate credentials.
- The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing.
This appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.
### Attack Chain
- Initial Access
- Execution
- Persistence
- Privilege Escalation
`;
const markdown = getAttackDiscoveryMarkdown({
attackDiscovery: mockAttackDiscovery,
replacements,
});
expect(markdown).toBe(expectedMarkdown);
});
});
});

View file

@ -0,0 +1,96 @@
/*
* 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 {
COMMAND_AND_CONTROL,
DISCOVERY,
EXECUTION,
EXFILTRATION,
getTacticLabel,
getTacticMetadata,
INITIAL_ACCESS,
LATERAL_MOVEMENT,
PERSISTENCE,
PRIVILEGE_ESCALATION,
RECONNAISSANCE,
replaceNewlineLiterals,
} from './helpers';
import { mockAttackDiscovery } from './mock/mock_attack_discovery';
import * as i18n from './translations';
const expectedTactics = {
[RECONNAISSANCE]: i18n.RECONNAISSANCE,
[INITIAL_ACCESS]: i18n.INITIAL_ACCESS,
[EXECUTION]: i18n.EXECUTION,
[PERSISTENCE]: i18n.PERSISTENCE,
[PRIVILEGE_ESCALATION]: i18n.PRIVILEGE_ESCALATION,
[DISCOVERY]: i18n.DISCOVERY,
[LATERAL_MOVEMENT]: i18n.LATERAL_MOVEMENT,
[COMMAND_AND_CONTROL]: i18n.COMMAND_AND_CONTROL,
[EXFILTRATION]: i18n.EXFILTRATION,
unknown: 'unknown',
};
describe('helpers', () => {
describe('getTacticLabel', () => {
Object.entries(expectedTactics).forEach(([tactic, expectedLabel]) => {
it(`returns the expected label for ${tactic}`, () => {
const label = getTacticLabel(tactic);
expect(label).toBe(expectedLabel);
});
});
});
describe('getTacticMetadata', () => {
const expectedDetected = ['Initial Access', 'Execution', 'Persistence', 'Privilege Escalation'];
expectedDetected.forEach((tactic) => {
it(`sets the detected property to true for the '${tactic}' tactic`, () => {
const result = getTacticMetadata(mockAttackDiscovery);
const metadata = result.find(({ name }) => name === tactic);
expect(metadata?.detected).toBe(true);
});
});
it('sets the detected property to false for all tactics that were not detected', () => {
const result = getTacticMetadata(mockAttackDiscovery);
const filtered = result.filter(({ name }) => !expectedDetected.includes(name));
filtered.forEach((metadata) => {
expect(metadata.detected).toBe(false);
});
});
it('sets the expected "index" property for each tactic', () => {
const result = getTacticMetadata(mockAttackDiscovery);
result.forEach((metadata, i) => {
expect(metadata.index).toBe(i);
});
});
});
describe('replaceNewlineLiterals', () => {
it('replaces multiple newline literals with actual newlines', () => {
const input = 'Multiple\\nnewline\\nliterals';
const expected = 'Multiple\nnewline\nliterals';
const result = replaceNewlineLiterals(input);
expect(result).toEqual(expected);
});
it('does NOT replace anything if there are no newline literals', () => {
const input = 'This is a string without newlines';
const result = replaceNewlineLiterals(input);
expect(result).toEqual(input);
});
});
});

View file

@ -56,7 +56,7 @@ export const getTacticLabel = (tactic: string): string => {
}
};
interface TacticMetadata {
export interface TacticMetadata {
detected: boolean;
index: number;
name: string;

View file

@ -0,0 +1,37 @@
/*
* 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 type { AttackDiscovery } from '@kbn/elastic-assistant-common';
export const mockAttackDiscovery: AttackDiscovery = {
alertIds: [
'639801cdb10a93610be4a91fe0eac94cd3d4d292cf0c2a6d7b3674d7f7390357',
'bdcf649846dc3ed0ca66537e1c1dc62035a35a208ba4d9853a93e9be4b0dbea3',
'cdbd13134bbd371cd045e5f89970b21ab866a9c3817b2aaba8d8e247ca88b823',
'58571e1653b4201c4f35d49b6eb4023fc0219d5885ff7c385a9253a692a77104',
'06fcb3563de7dad14137c0bb4e5bae17948c808b8a3b8c60d9ec209a865b20ed',
'8bd3fcaeca5698ee26df402c8bc40df0404d34a278bc0bd9355910c8c92a4aee',
'59ff4efa1a03b0d1cb5c0640f5702555faf5c88d273616c1b6e22dcfc47ac46c',
'f352f8ca14a12062cde77ff2b099202bf74f4a7d757c2ac75ac63690b2f2f91a',
],
detailsMarkdown:
'The following attack progression appears to have occurred on the host {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} involving the user {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}:\n\n- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation.\n- This application spawned child processes to copy a malicious file named "unix1" to the user\'s home directory and make it executable.\n- The malicious "unix1" file was then executed, attempting to access the user\'s login keychain and potentially exfiltrate credentials.\n- The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing.\n\nThis appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.',
entitySummaryMarkdown:
'Suspicious activity involving the host {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} and user {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}.',
id: 'e6d1f8ef-7c1d-42d6-ba6a-11610bab72b1',
mitreAttackTactics: [
'Initial Access',
'Execution',
'Persistence',
'Privilege Escalation',
'Credential Access',
],
summaryMarkdown:
'A multi-stage malware attack was detected on {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} involving {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}. A suspicious application delivered malware, attempted credential theft, and established persistence.',
timestamp: '2024-06-25T21:14:40.098Z',
title: 'Malware Attack With Credential Theft Attempt',
};

View file

@ -195,3 +195,87 @@ export const getMockUseAttackDiscoveriesWithNoAttackDiscoveriesLoading = (
replacements: {},
isLoading: true, // <-- attack discoveries are being generated
});
export const getRawAttackDiscoveryResponse = () => ({
alertsContextCount: 20,
attackDiscoveries: [
{
alertIds: [
'382d546a7ba5ab35c050f106bece236e87e3d51076a479f0beae8b2015b8fb26',
'ca9da6b3b77b7038d958b9e144f0a406c223a862c0c991ce9782b98e03a98c87',
'5301f4fb014538df7ce1eb9929227dde3adc0bf5b4f28aa15c8aa4e4fda95f35',
'1459af4af8b92e1710c0ee075b1c444eaa927583dfd71b42e9a10de37c8b9cf0',
'468457e9c5132aadae501b75ec5b766e1465ab865ad8d79e03f66593a76fccdf',
'fb92e7fa5679db3e91d84d998faddb7ed269f1c8cdc40443f35e67c930383d34',
'03e0f8f1598018da8143bba6b60e6ddea30551a2286ba76d717568eed3d17a66',
'28021a7aca7de03018d820182c9784f8d5f2e1b99e0159177509a69bee1c3ac0',
],
detailsMarkdown:
'The following attack progression appears to have occurred on the host {{ host.name 05207978-1585-4e46-9b36-69c4bb85a768 }} involving the user {{ user.name ddc8db29-46eb-44fe-80b6-1ea642c338ac }}:\\n\\n- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation\\n- This application attempted to run various malicious scripts and commands, including:\\n - Spawning a child process to run the "osascript" utility to display a fake system dialog prompting for user credentials ({{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\t\\t\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }})\\n - Modifying permissions on a suspicious file named "unix1" ({{ process.command_line chmod 777 /Users/james/unix1 }})\\n - Executing the suspicious "unix1" file and passing it the user\'s login keychain file and a hardcoded password ({{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }})\\n\\nThis appears to be a multi-stage malware attack, potentially aimed at credential theft and further malicious execution on the compromised host. The tactics used align with Credential Access ({{ threat.tactic.name Credential Access }}) and Execution ({{ threat.tactic.name Execution }}) based on MITRE ATT&CK.',
entitySummaryMarkdown:
'Suspicious activity detected on {{ host.name 05207978-1585-4e46-9b36-69c4bb85a768 }} involving {{ user.name ddc8db29-46eb-44fe-80b6-1ea642c338ac }}.',
mitreAttackTactics: ['Credential Access', 'Execution'],
summaryMarkdown:
'A multi-stage malware attack was detected on a macOS host, likely initiated through a malicious application download. The attack involved credential phishing attempts, suspicious file modifications, and the execution of untrusted binaries potentially aimed at credential theft. {{ host.name 05207978-1585-4e46-9b36-69c4bb85a768 }} and {{ user.name ddc8db29-46eb-44fe-80b6-1ea642c338ac }} were involved.',
title: 'Credential Theft Malware Attack on macOS',
},
{
alertIds: [
'8772effc4970e371a26d556556f68cb8c73f9d9d9482b7f20ee1b1710e642a23',
'63c761718211fa51ea797669d845c3d4f23b1a28c77a101536905e6fd0b4aaa6',
'55f4641a9604e1088deae4897e346e63108bde9167256c7cb236164233899dcc',
'eaf9991c83feef7798983dc7cacda86717d77136a3a72c9122178a03ce2f15d1',
'f7044f707ac119256e5a0ccd41d451b51bca00bdc6899c7e5e8e1edddfeb6774',
'fad83b4223f3c159646ad22df9877b9c400f9472655e49781e2a5951b641088e',
],
detailsMarkdown:
'The following attack progression appears to have occurred on the host {{ host.name b775910b-4b71-494d-bfb1-4be3fe88c2b0 }} involving the user {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }}:\\n\\n- A malicious Microsoft Office document was opened, spawning a child process to write a suspicious VBScript file named "AppPool.vbs" ({{ file.path C:\\ProgramData\\WindowsAppPool\\AppPool.vbs }})\\n- The VBScript launched PowerShell and executed an obfuscated script from "AppPool.ps1"\\n- Additional malicious activities were performed, including:\\n - Creating a scheduled task to periodically execute the VBScript\\n - Spawning a cmd.exe process to create the scheduled task\\n - Executing the VBScript directly\\n\\nThis appears to be a multi-stage malware attack initiated through malicious Office documents, employing script obfuscation, scheduled task persistence, and defense evasion tactics. The activities map to Initial Access ({{ threat.tactic.name Initial Access }}), Execution ({{ threat.tactic.name Execution }}), and Defense Evasion ({{ threat.tactic.name Defense Evasion }}) based on MITRE ATT&CK.',
entitySummaryMarkdown:
'Suspicious activity detected on {{ host.name b775910b-4b71-494d-bfb1-4be3fe88c2b0 }} involving {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }}.',
mitreAttackTactics: ['Initial Access', 'Execution', 'Defense Evasion'],
summaryMarkdown:
'A multi-stage malware attack was detected on a Windows host, likely initiated through a malicious Microsoft Office document. The attack involved script obfuscation, scheduled task persistence, and other defense evasion tactics. {{ host.name b775910b-4b71-494d-bfb1-4be3fe88c2b0 }} and {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }} were involved.',
title: 'Malicious Office Document Initiates Malware Attack',
},
{
alertIds: [
'd1b8b1c6f891fd181af236d0a81b8769c4569016d5b341cdf6a3fefb7cf9cbfd',
'005f2dfb7efb08b34865b308876ecad188fc9a3eebf35b5e3af3c3780a3fb239',
'7e41ddd221831544c5ff805e0ec31fc3c1f22c04257de1366112cfef14df9f63',
],
detailsMarkdown:
'The following attack progression appears to have occurred on the host {{ host.name c1e00157-c636-4222-b3a2-5d9ea667a3a8 }} involving the user {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }}:\\n\\n- A suspicious process launched by msiexec.exe spawned a PowerShell session\\n- The PowerShell process exhibited the following malicious behaviors:\\n - Shellcode injection detected, indicating the presence of the "Windows.Trojan.Bumblebee" malware\\n - Establishing network connections, suggesting command and control or data exfiltration\\n\\nThis appears to be a case of malware delivery and execution via an MSI package, potentially initiated through a software supply chain compromise or social engineering attack. The tactics employed align with Defense Evasion ({{ threat.tactic.name Defense Evasion }}) through system binary proxy execution, as well as potential Command and Control ({{ threat.tactic.name Command and Control }}) based on MITRE ATT&CK.',
entitySummaryMarkdown:
'Suspicious activity detected on {{ host.name c1e00157-c636-4222-b3a2-5d9ea667a3a8 }} involving {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }}.',
mitreAttackTactics: ['Defense Evasion', 'Command and Control'],
summaryMarkdown:
'A malware attack was detected on a Windows host, likely delivered through a compromised MSI package. The attack involved shellcode injection, network connections, and the use of system binaries for defense evasion. {{ host.name c1e00157-c636-4222-b3a2-5d9ea667a3a8 }} and {{ user.name e411fe2e-aeea-44b5-b09a-4336dabb3969 }} were involved.',
title: 'Malware Delivery via Compromised MSI Package',
},
{
alertIds: [
'12057d82e79068080f6acf268ca45c777d3f80946b466b59954320ec5f86f24a',
'81c7c57a360bee531b1398b0773e7c4a2332fbdda4e66f135e01fc98ec7f4e3d',
],
detailsMarkdown:
'The following attack progression appears to have occurred on the host {{ host.name d4c92b0d-b82f-4702-892d-dd06ad8418e8 }} involving the user {{ user.name 7245f867-9a09-48d7-9165-84a69fa0727d }}:\\n\\n- A malicious file named "kdmtmpflush" with the SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} was copied to the /dev/shm directory\\n- Permissions were modified to make the file executable\\n- The file was then executed with the "--init" argument, likely to initialize malicious components\\n\\nThis appears to be a case of the "Linux.Trojan.BPFDoor" malware being deployed on the Linux host. The tactics employed align with Execution ({{ threat.tactic.name Execution }}) based on MITRE ATT&CK.',
entitySummaryMarkdown:
'Suspicious activity detected on {{ host.name d4c92b0d-b82f-4702-892d-dd06ad8418e8 }} involving {{ user.name 7245f867-9a09-48d7-9165-84a69fa0727d }}.',
mitreAttackTactics: ['Execution'],
summaryMarkdown:
'The "Linux.Trojan.BPFDoor" malware was detected being deployed on a Linux host. A malicious file was copied, permissions were modified, and the file was executed to likely initialize malicious components. {{ host.name d4c92b0d-b82f-4702-892d-dd06ad8418e8 }} and {{ user.name 7245f867-9a09-48d7-9165-84a69fa0727d }} were involved.',
title: 'Linux.Trojan.BPFDoor Malware Deployment Detected',
},
],
connector_id: 'pmeClaudeV3SonnetUsEast1',
replacements: {
'ddc8db29-46eb-44fe-80b6-1ea642c338ac': 'james',
'05207978-1585-4e46-9b36-69c4bb85a768': 'SRVMAC08',
'7245f867-9a09-48d7-9165-84a69fa0727d': 'root',
'e411fe2e-aeea-44b5-b09a-4336dabb3969': 'Administrator',
'5a63f6dc-4e40-41fe-a92c-7898e891025e': 'SRVWIN07-PRIV',
'b775910b-4b71-494d-bfb1-4be3fe88c2b0': 'SRVWIN07',
'c1e00157-c636-4222-b3a2-5d9ea667a3a8': 'SRVWIN06',
'd4c92b0d-b82f-4702-892d-dd06ad8418e8': 'SRVNIX05',
},
});

View file

@ -0,0 +1,25 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { AnimatedCounter } from '.';
describe('AnimatedCounter', () => {
it('renders the expected final count', async () => {
const animationDurationMs = 10; // ms
const count = 20;
render(<AnimatedCounter animationDurationMs={animationDurationMs} count={count} />);
await new Promise((resolve) => setTimeout(resolve, animationDurationMs + 10));
const animatedCounter = screen.getByTestId('animatedCounter');
expect(animatedCounter).toHaveTextContent(`${count}`);
});
});

View file

@ -11,14 +11,14 @@ import * as d3 from 'd3';
import React, { useRef, useEffect } from 'react';
interface Props {
animationDurationMs?: number;
count: number;
}
const AnimatedCounterComponent: React.FC<Props> = ({ count }) => {
const AnimatedCounterComponent: React.FC<Props> = ({ animationDurationMs = 1000 * 1, count }) => {
const { euiTheme } = useEuiTheme();
const d3Ref = useRef(null);
const zero = 0; // counter starts at zero
const animationDurationMs = 1000 * 1;
useEffect(() => {
if (d3Ref.current) {

View file

@ -0,0 +1,150 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { EmptyPrompt } from '.';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
import { TestProviders } from '../../../common/mock';
jest.mock('../../../assistant/use_assistant_availability');
describe('EmptyPrompt', () => {
const alertsCount = 20;
const onGenerate = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('when the user has the assistant privilege', () => {
beforeEach(() => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: true,
isAssistantEnabled: true,
});
render(
<TestProviders>
<EmptyPrompt
alertsCount={alertsCount}
isLoading={false}
isDisabled={false}
onGenerate={onGenerate}
/>
</TestProviders>
);
});
it('renders the empty prompt avatar', () => {
const emptyPromptAvatar = screen.getByTestId('emptyPromptAvatar');
expect(emptyPromptAvatar).toBeInTheDocument();
});
it('renders the animated counter', () => {
const emptyPromptAnimatedCounter = screen.getByTestId('emptyPromptAnimatedCounter');
expect(emptyPromptAnimatedCounter).toBeInTheDocument();
});
it('renders the expected statement', () => {
const emptyPromptAlertsWillBeAnalyzed = screen.getByTestId('emptyPromptAlertsWillBeAnalyzed');
expect(emptyPromptAlertsWillBeAnalyzed).toHaveTextContent('alerts will be analyzed');
});
it('calls onGenerate when the generate button is clicked', () => {
const generateButton = screen.getByTestId('generate');
fireEvent.click(generateButton);
expect(onGenerate).toHaveBeenCalled();
});
});
describe('when the user does NOT have the assistant privilege', () => {
it('disables the generate button when the user does NOT have the assistant privilege', () => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: false, // <-- the user does NOT have the assistant privilege
isAssistantEnabled: true,
});
render(
<TestProviders>
<EmptyPrompt
alertsCount={alertsCount}
isLoading={false}
isDisabled={false}
onGenerate={onGenerate}
/>
</TestProviders>
);
const generateButton = screen.getByTestId('generate');
expect(generateButton).toBeDisabled();
});
});
describe('when loading is true', () => {
const isLoading = true;
beforeEach(() => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: true,
isAssistantEnabled: true,
});
render(
<TestProviders>
<EmptyPrompt
alertsCount={alertsCount}
isLoading={isLoading}
isDisabled={false}
onGenerate={onGenerate}
/>
</TestProviders>
);
});
it('disables the generate button while loading', () => {
const generateButton = screen.getByTestId('generate');
expect(generateButton).toBeDisabled();
});
});
describe('when isDisabled is true', () => {
const isDisabled = true;
beforeEach(() => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: true,
isAssistantEnabled: true,
});
render(
<TestProviders>
<EmptyPrompt
alertsCount={alertsCount}
isLoading={false}
isDisabled={isDisabled}
onGenerate={onGenerate}
/>
</TestProviders>
);
});
it('disables the generate button when isDisabled is true', () => {
const generateButton = screen.getByTestId('generate');
expect(generateButton).toBeDisabled();
});
});
});

View file

@ -0,0 +1,183 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { Header } from '.';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
import { TestProviders } from '../../../common/mock';
jest.mock('../../../assistant/use_assistant_availability');
describe('Header', () => {
beforeEach(() => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: true,
isAssistantEnabled: true,
});
});
it('renders the connector selector', () => {
render(
<TestProviders>
<Header
connectorId="testConnectorId"
connectorsAreConfigured={true}
isDisabledActions={false}
isLoading={false}
onCancel={jest.fn()}
onGenerate={jest.fn()}
onConnectorIdSelected={jest.fn()}
/>
</TestProviders>
);
const connectorSelector = screen.getByTestId('connectorSelectorPlaceholderButton');
expect(connectorSelector).toBeInTheDocument();
});
it('does NOT render the connector selector when connectors are NOT configured', () => {
const connectorsAreConfigured = false;
render(
<TestProviders>
<Header
connectorId="testConnectorId"
connectorsAreConfigured={connectorsAreConfigured}
isDisabledActions={false}
isLoading={false}
onCancel={jest.fn()}
onGenerate={jest.fn()}
onConnectorIdSelected={jest.fn()}
/>
</TestProviders>
);
const connectorSelector = screen.queryByTestId('connectorSelectorPlaceholderButton');
expect(connectorSelector).not.toBeInTheDocument();
});
it('invokes onGenerate when the generate button is clicked', () => {
const onGenerate = jest.fn();
render(
<TestProviders>
<Header
connectorId="testConnectorId"
connectorsAreConfigured={true}
isDisabledActions={false}
isLoading={false}
onCancel={jest.fn()}
onConnectorIdSelected={jest.fn()}
onGenerate={onGenerate}
/>
</TestProviders>
);
const generate = screen.getByTestId('generate');
fireEvent.click(generate);
expect(onGenerate).toHaveBeenCalled();
});
it('disables the generate button when the user does NOT have the assistant privilege', () => {
(useAssistantAvailability as jest.Mock).mockReturnValue({
hasAssistantPrivilege: false,
isAssistantEnabled: true,
});
render(
<TestProviders>
<Header
connectorId="testConnectorId"
connectorsAreConfigured={true}
isDisabledActions={false}
isLoading={false}
onCancel={jest.fn()}
onConnectorIdSelected={jest.fn()}
onGenerate={jest.fn()}
/>
</TestProviders>
);
const generate = screen.getByTestId('generate');
expect(generate).toBeDisabled();
});
it('displays the cancel button when loading', () => {
const isLoading = true;
render(
<TestProviders>
<Header
connectorId="testConnectorId"
connectorsAreConfigured={true}
isDisabledActions={false}
isLoading={isLoading}
onCancel={jest.fn()}
onConnectorIdSelected={jest.fn()}
onGenerate={jest.fn()}
/>
</TestProviders>
);
const cancel = screen.getByTestId('cancel');
expect(cancel).toBeInTheDocument();
});
it('invokes onCancel when the cancel button is clicked', () => {
const isLoading = true;
const onCancel = jest.fn();
render(
<TestProviders>
<Header
connectorId="testConnectorId"
connectorsAreConfigured={true}
isDisabledActions={false}
isLoading={isLoading}
onCancel={onCancel}
onConnectorIdSelected={jest.fn()}
onGenerate={jest.fn()}
/>
</TestProviders>
);
const cancel = screen.getByTestId('cancel');
fireEvent.click(cancel);
expect(onCancel).toHaveBeenCalled();
});
it('disables the generate button when connectorId is undefined', () => {
const connectorId = undefined;
render(
<TestProviders>
<Header
connectorId={connectorId}
connectorsAreConfigured={true}
isDisabledActions={false}
isLoading={false}
onCancel={jest.fn()}
onConnectorIdSelected={jest.fn()}
onGenerate={jest.fn()}
/>
</TestProviders>
);
const generate = screen.getByTestId('generate');
expect(generate).toBeDisabled();
});
});

View file

@ -0,0 +1,84 @@
/*
* 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 moment from 'moment';
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { Countdown } from '.';
import { TestProviders } from '../../../../common/mock';
import { APPROXIMATE_TIME_REMAINING } from './translations';
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
describe('Countdown', () => {
const connectorIntervals: GenerationInterval[] = [
{
date: '2024-05-16T14:13:09.838Z',
durationMs: 173648,
},
{
date: '2024-05-16T13:59:49.620Z',
durationMs: 146605,
},
{
date: '2024-05-16T13:47:00.629Z',
durationMs: 255163,
},
];
beforeAll(() => {
jest.useFakeTimers({ legacyFakeTimers: true });
});
beforeEach(() => {
jest.clearAllTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it('returns null when connectorIntervals is empty', () => {
const { container } = render(
<TestProviders>
<Countdown approximateFutureTime={null} connectorIntervals={[]} />
</TestProviders>
);
expect(container.innerHTML).toEqual('');
});
it('renders the expected prefix', () => {
render(
<TestProviders>
<Countdown approximateFutureTime={null} connectorIntervals={connectorIntervals} />
</TestProviders>
);
expect(screen.getByTestId('prefix')).toHaveTextContent(APPROXIMATE_TIME_REMAINING);
});
it('renders the expected the timer text', () => {
const approximateFutureTime = moment().add(1, 'minute').toDate();
render(
<TestProviders>
<Countdown
approximateFutureTime={approximateFutureTime}
connectorIntervals={connectorIntervals}
/>
</TestProviders>
);
act(() => {
jest.runOnlyPendingTimers();
});
expect(screen.getByTestId('timerText')).toHaveTextContent('00:59');
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../../../common/mock';
import { GenerationTiming } from '.';
describe('GenerationTiming', () => {
const interval = {
connectorId: 'claudeV3SonnetUsEast1',
date: '2024-04-15T13:48:44.397Z',
durationMs: 5000,
};
beforeEach(() => {
render(
<TestProviders>
<GenerationTiming interval={interval} />
</TestProviders>
);
});
it('renders the expected duration in seconds', () => {
const durationText = screen.getByTestId('clockBadge').textContent;
expect(durationText).toEqual('5s');
});
it('displays the expected date', () => {
const date = screen.getByTestId('date').textContent;
expect(date).toEqual('Apr 15, 2024 @ 13:48:44.397');
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 moment from 'moment';
import { getAverageIntervalSeconds, getTimerPrefix } from './helpers';
import { APPROXIMATE_TIME_REMAINING, ABOVE_THE_AVERAGE_TIME } from '../translations';
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
describe('helpers', () => {
describe('getAverageIntervalSeconds', () => {
it('returns 0 when the intervals array is empty', () => {
const intervals: GenerationInterval[] = [];
const average = getAverageIntervalSeconds(intervals);
expect(average).toEqual(0);
});
it('calculates the average interval in seconds', () => {
const intervals: GenerationInterval[] = [
{
date: '2024-04-15T13:48:44.397Z',
durationMs: 85807,
},
{
date: '2024-04-15T12:41:15.255Z',
durationMs: 12751,
},
{
date: '2024-04-12T20:59:13.238Z',
durationMs: 46169,
},
{
date: '2024-04-12T19:34:56.701Z',
durationMs: 86674,
},
];
const average = getAverageIntervalSeconds(intervals);
expect(average).toEqual(57);
});
});
describe('getTimerPrefix', () => {
it('returns APPROXIMATE_TIME_REMAINING when approximateFutureTime is null', () => {
const approximateFutureTime: Date | null = null;
const result = getTimerPrefix(approximateFutureTime);
expect(result).toEqual(APPROXIMATE_TIME_REMAINING);
});
it('returns APPROXIMATE_TIME_REMAINING when approximateFutureTime is in the future', () => {
const approximateFutureTime = moment().add(1, 'minute').toDate();
const result = getTimerPrefix(approximateFutureTime);
expect(result).toEqual(APPROXIMATE_TIME_REMAINING);
});
it('returns ABOVE_THE_AVERAGE_TIME when approximateFutureTime is in the past', () => {
const approximateFutureTime = moment().subtract(1, 'minute').toDate();
const result = getTimerPrefix(approximateFutureTime);
expect(result).toEqual(ABOVE_THE_AVERAGE_TIME);
});
});
});

View file

@ -0,0 +1,59 @@
/*
* 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 type { GenerationInterval } from '@kbn/elastic-assistant-common';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { LastTimesPopover } from '.';
import { TestProviders } from '../../../../../common/mock';
describe('LastTimesPopover', () => {
const connectorIntervals: GenerationInterval[] = [
{
date: '2024-05-16T14:13:09.838Z',
durationMs: 173648,
},
{
date: '2024-05-16T13:59:49.620Z',
durationMs: 146605,
},
{
date: '2024-05-16T13:47:00.629Z',
durationMs: 255163,
},
];
beforeEach(() => {
render(
<TestProviders>
<LastTimesPopover connectorIntervals={connectorIntervals} />
</TestProviders>
);
});
it('renders average time calculated message', () => {
const averageTimeIsCalculated = screen.getByTestId('averageTimeIsCalculated');
expect(averageTimeIsCalculated).toHaveTextContent(
'Remaining time is based on the average speed of the last 3 times the same connector generated results.'
);
});
it('renders generation timing for each connector interval', () => {
const generationTimings = screen.getAllByTestId('generationTiming');
expect(generationTimings.length).toEqual(connectorIntervals.length);
const expectedDates = [
'May 16, 2024 @ 14:13:09.838',
'May 16, 2024 @ 13:59:49.620',
'May 16, 2024 @ 13:47:00.629',
];
generationTimings.forEach((timing, i) => expect(timing).toHaveTextContent(expectedDates[i]));
});
});

View file

@ -0,0 +1,73 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { LoadingCallout } from '.';
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
import { TestProviders } from '../../../common/mock';
describe('LoadingCallout', () => {
const connectorIntervals: GenerationInterval[] = [
{
date: '2024-05-16T14:13:09.838Z',
durationMs: 173648,
},
{
date: '2024-05-16T13:59:49.620Z',
durationMs: 146605,
},
{
date: '2024-05-16T13:47:00.629Z',
durationMs: 255163,
},
];
const defaultProps = {
alertsCount: 30,
approximateFutureTime: new Date(),
connectorIntervals,
};
it('renders the animated loading icon', () => {
render(
<TestProviders>
<LoadingCallout {...defaultProps} />
</TestProviders>
);
const loadingElastic = screen.getByTestId('loadingElastic');
expect(loadingElastic).toBeInTheDocument();
});
it('renders loading messages with the expected count', () => {
render(
<TestProviders>
<LoadingCallout {...defaultProps} />
</TestProviders>
);
const aisCurrentlyAnalyzing = screen.getByTestId('aisCurrentlyAnalyzing');
expect(aisCurrentlyAnalyzing).toHaveTextContent(
'AI is analyzing up to 30 alerts in the last 24 hours to generate discoveries.'
);
});
it('renders the countdown', () => {
render(
<TestProviders>
<LoadingCallout {...defaultProps} />
</TestProviders>
);
const countdown = screen.getByTestId('countdown');
expect(countdown).toBeInTheDocument();
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 type { GenerationInterval } from '@kbn/elastic-assistant-common';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { InfoPopoverBody } from '.';
import { TestProviders } from '../../../../common/mock';
import { AVERAGE_TIME } from '../countdown/translations';
describe('InfoPopoverBody', () => {
const connectorIntervals: GenerationInterval[] = [
{
date: '2024-05-16T14:13:09.838Z',
durationMs: 173648,
},
{
date: '2024-05-16T13:59:49.620Z',
durationMs: 146605,
},
{
date: '2024-05-16T13:47:00.629Z',
durationMs: 255163,
},
];
it('renders the expected average time', () => {
render(
<TestProviders>
<InfoPopoverBody connectorIntervals={connectorIntervals} />
</TestProviders>
);
const averageTimeBadge = screen.getByTestId('averageTimeBadge');
expect(averageTimeBadge).toHaveTextContent('191s');
});
it('renders the expected explanation', () => {
render(
<TestProviders>
<InfoPopoverBody connectorIntervals={connectorIntervals} />
</TestProviders>
);
const averageTimeIsCalculated = screen.getAllByTestId('averageTimeIsCalculated');
expect(averageTimeIsCalculated[0]).toHaveTextContent(AVERAGE_TIME);
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { LoadingMessages } from '.';
import { TestProviders } from '../../../../common/mock';
import { ATTACK_DISCOVERY_GENERATION_IN_PROGRESS } from '../translations';
describe('LoadingMessages', () => {
it('renders the expected loading message', () => {
render(
<TestProviders>
<LoadingMessages alertsCount={20} />
</TestProviders>
);
const attackDiscoveryGenerationInProgress = screen.getByTestId(
'attackDiscoveryGenerationInProgress'
);
expect(attackDiscoveryGenerationInProgress).toHaveTextContent(
ATTACK_DISCOVERY_GENERATION_IN_PROGRESS
);
});
it('renders the loading message with the expected alerts count', () => {
render(
<TestProviders>
<LoadingMessages alertsCount={20} />
</TestProviders>
);
const aiCurrentlyAnalyzing = screen.getByTestId('aisCurrentlyAnalyzing');
expect(aiCurrentlyAnalyzing).toHaveTextContent(
'AI is analyzing up to 20 alerts in the last 24 hours to generate discoveries.'
);
});
});

View file

@ -0,0 +1,22 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { PageTitle } from '.';
import { ATTACK_DISCOVERY_PAGE_TITLE } from './translations';
describe('PageTitle', () => {
it('renders the expected title', () => {
render(<PageTitle />);
const attackDiscoveryPageTitle = screen.getByTestId('attackDiscoveryPageTitle');
expect(attackDiscoveryPageTitle).toHaveTextContent(ATTACK_DISCOVERY_PAGE_TITLE);
});
});

View file

@ -0,0 +1,56 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { Summary } from '.';
describe('Summary', () => {
const defaultProps = {
alertsCount: 20,
attackDiscoveriesCount: 5,
lastUpdated: new Date(),
onToggleShowAnonymized: jest.fn(),
showAnonymized: false,
};
beforeEach(() => jest.clearAllMocks());
it('renders the expected summary counts', () => {
render(<Summary {...defaultProps} />);
const summaryCount = screen.getByTestId('summaryCount');
expect(summaryCount).toHaveTextContent('5 discoveries|20 alerts|Generated: a few seconds ago');
});
it('renders the expected button icon when showAnonymized is false', () => {
render(<Summary {...defaultProps} />);
const toggleAnonymized = screen.getByTestId('toggleAnonymized').querySelector('span');
expect(toggleAnonymized).toHaveAttribute('data-euiicon-type', 'eyeClosed');
});
it('renders the expected button icon when showAnonymized is true', () => {
render(<Summary {...defaultProps} showAnonymized={true} />);
const toggleAnonymized = screen.getByTestId('toggleAnonymized').querySelector('span');
expect(toggleAnonymized).toHaveAttribute('data-euiicon-type', 'eye');
});
it('calls onToggleShowAnonymized when toggle button is clicked', () => {
render(<Summary {...defaultProps} />);
const toggleAnonymized = screen.getByTestId('toggleAnonymized');
fireEvent.click(toggleAnonymized);
expect(defaultProps.onToggleShowAnonymized).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { SummaryCount } from '.';
describe('SummaryCount', () => {
const defaultProps = {
alertsCount: 20,
attackDiscoveriesCount: 5,
lastUpdated: new Date(),
};
it('renders the expected count of attack discoveries', () => {
render(<SummaryCount {...defaultProps} />);
const discoveriesCount = screen.getByTestId('discoveriesCount');
expect(discoveriesCount).toHaveTextContent('5 discoveries');
});
it('renders the expected alerts count', () => {
render(<SummaryCount {...defaultProps} />);
const alertsCount = screen.getByTestId('alertsCount');
expect(alertsCount).toHaveTextContent('20 alerts');
});
it('renders a humanized last generated when lastUpdated is provided', () => {
render(<SummaryCount {...defaultProps} />);
const lastGenerated = screen.getByTestId('lastGenerated');
expect(lastGenerated).toHaveTextContent('Generated: a few seconds ago');
});
it('should NOT render the last generated date when lastUpdated is null', () => {
render(<SummaryCount {...defaultProps} lastUpdated={null} />);
const lastGenerated = screen.queryByTestId('lastGenerated');
expect(lastGenerated).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { Upgrade } from '.';
import { TestProviders } from '../../../common/mock';
import {
ATTACK_DISCOVERY_IS_AVAILABLE,
FIND_POTENTIAL_ATTACKS_WITH_AI,
PLEASE_UPGRADE,
} from './translations';
describe('Upgrade', () => {
beforeEach(() => {
render(
<TestProviders>
<Upgrade />
</TestProviders>
);
});
it('renders the assistant avatar', () => {
const assistantAvatar = screen.getByTestId('assistantAvatar');
expect(assistantAvatar).toBeInTheDocument();
});
it('renders the expected upgrade title', () => {
const upgradeTitle = screen.getByTestId('upgradeTitle');
expect(upgradeTitle).toHaveTextContent(FIND_POTENTIAL_ATTACKS_WITH_AI);
});
it('renders the attack discovery availability text', () => {
const attackDiscoveryIsAvailable = screen.getByTestId('attackDiscoveryIsAvailable');
expect(attackDiscoveryIsAvailable).toHaveTextContent(ATTACK_DISCOVERY_IS_AVAILABLE);
});
it('renders the please upgrade text', () => {
const pleaseUpgrade = screen.getByTestId('pleaseUpgrade');
expect(pleaseUpgrade).toHaveTextContent(PLEASE_UPGRADE);
});
it('renders the upgrade subscription plans (docs) link', () => {
const upgradeDocs = screen.getByRole('link', { name: 'Subscription plans' });
expect(upgradeDocs).toBeInTheDocument();
});
it('renders the upgrade Manage license call to action', () => {
const upgradeCta = screen.getByRole('link', { name: 'Manage license' });
expect(upgradeCta).toBeInTheDocument();
});
});

View file

@ -0,0 +1,284 @@
/*
* 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 { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import { omit } from 'lodash/fp';
import { getGenAiConfig, getRequestBody } from './helpers';
const connector: ActionConnector = {
actionTypeId: '.gen-ai',
config: {
apiProvider: 'Azure OpenAI',
apiUrl:
'https://example.com/openai/deployments/example/chat/completions?api-version=2024-02-15-preview',
},
id: '15b4f8df-e2ca-4060-81a1-3bd2a2bffc7e',
isDeprecated: false,
isMissingSecrets: false,
isPreconfigured: false,
isSystemAction: false,
name: 'Azure OpenAI GPT-4o',
secrets: { secretTextField: 'a secret' },
};
describe('getGenAiConfig', () => {
it('returns undefined when the connector is preconfigured', () => {
const preconfigured = {
...connector,
isPreconfigured: true,
};
const result = getGenAiConfig(preconfigured);
expect(result).toBeUndefined();
});
it('returns the expected GenAiConfig when the connector is NOT preconfigured', () => {
const result = getGenAiConfig(connector);
expect(result).toEqual({
apiProvider: connector.config.apiProvider,
apiUrl: connector.config.apiUrl,
defaultModel: '2024-02-15-preview',
});
});
it('returns the expected defaultModel for Azure OpenAI', () => {
const result = getGenAiConfig(connector);
expect(result).toEqual({
apiProvider: connector.config.apiProvider,
apiUrl: connector.config.apiUrl,
defaultModel: '2024-02-15-preview',
});
});
it('returns the an undefined defaultModel for NON-Azure OpenAI when the config does NOT include a default model', () => {
const apiProvider = 'OpenAI'; // <-- NON-Azure OpenAI
const openAiConnector = {
...connector,
config: {
...connector.config,
apiProvider,
// config does NOT have a default model
},
};
const result = getGenAiConfig(openAiConnector);
expect(result).toEqual({
apiProvider,
apiUrl: connector.config.apiUrl,
defaultModel: undefined, // <-- because the config does not have a default model
});
});
it('returns the expected defaultModel for NON-Azure OpenAi when the config has a default model', () => {
const apiProvider = 'OpenAI'; // <-- NON-Azure OpenAI
const withDefaultModel = {
...connector,
config: {
...connector.config,
apiProvider,
defaultModel: 'aDefaultModel', // <-- default model is specified
},
};
const result = getGenAiConfig(withDefaultModel);
expect(result).toEqual({
apiProvider,
apiUrl: connector.config.apiUrl,
defaultModel: 'aDefaultModel',
});
});
it('returns the expected GenAiConfig when the connector config is undefined', () => {
const connectorWithoutConfig = omit('config', connector) as ActionConnector<
Record<string, unknown>,
Record<string, unknown>
>;
const result = getGenAiConfig(connectorWithoutConfig);
expect(result).toEqual({
apiProvider: undefined,
apiUrl: undefined,
defaultModel: undefined,
});
});
});
describe('getRequestBody', () => {
const alertsIndexPattern = 'test-index-pattern';
const anonymizationFields = {
page: 1,
perPage: 10,
total: 100,
data: [
{
id: '1',
field: 'field1',
},
{
id: '2',
field: 'field2',
},
],
};
const knowledgeBase = {
isEnabledKnowledgeBase: true,
isEnabledRAGAlerts: true,
latestAlerts: 20,
};
const traceOptions = {
apmUrl: '/app/apm',
langSmithProject: '',
langSmithApiKey: '',
};
it('returns the expected AttackDiscoveryPostRequestBody', () => {
const result = getRequestBody({
alertsIndexPattern,
anonymizationFields,
knowledgeBase,
traceOptions,
});
expect(result).toEqual({
alertsIndexPattern,
anonymizationFields: anonymizationFields.data,
apiConfig: {
actionTypeId: '',
connectorId: '',
model: undefined,
provider: undefined,
},
langSmithProject: undefined,
langSmithApiKey: undefined,
size: knowledgeBase.latestAlerts,
replacements: {},
subAction: 'invokeAI',
});
});
it('returns the expected AttackDiscoveryPostRequestBody when alertsIndexPattern is undefined', () => {
const result = getRequestBody({
alertsIndexPattern: undefined,
anonymizationFields,
knowledgeBase,
traceOptions,
});
expect(result).toEqual({
alertsIndexPattern: '',
anonymizationFields: anonymizationFields.data,
apiConfig: {
actionTypeId: '',
connectorId: '',
model: undefined,
provider: undefined,
},
langSmithProject: undefined,
langSmithApiKey: undefined,
size: knowledgeBase.latestAlerts,
replacements: {},
subAction: 'invokeAI',
});
});
it('returns the expected AttackDiscoveryPostRequestBody when LangSmith details are provided', () => {
const withLangSmith = {
alertsIndexPattern,
anonymizationFields,
knowledgeBase,
traceOptions: {
apmUrl: '/app/apm',
langSmithProject: 'A project',
langSmithApiKey: 'an API key',
},
};
const result = getRequestBody(withLangSmith);
expect(result).toEqual({
alertsIndexPattern,
anonymizationFields: anonymizationFields.data,
apiConfig: {
actionTypeId: '',
connectorId: '',
model: undefined,
provider: undefined,
},
langSmithApiKey: withLangSmith.traceOptions.langSmithApiKey,
langSmithProject: withLangSmith.traceOptions.langSmithProject,
size: knowledgeBase.latestAlerts,
replacements: {},
subAction: 'invokeAI',
});
});
it('returns the expected AttackDiscoveryPostRequestBody with the expected apiConfig when selectedConnector is provided', () => {
const result = getRequestBody({
alertsIndexPattern,
anonymizationFields,
knowledgeBase,
selectedConnector: connector, // <-- selectedConnector is provided
traceOptions,
});
expect(result).toEqual({
alertsIndexPattern,
anonymizationFields: anonymizationFields.data,
apiConfig: {
actionTypeId: connector.actionTypeId,
connectorId: connector.id,
model: undefined,
provider: undefined,
},
langSmithProject: undefined,
langSmithApiKey: undefined,
size: knowledgeBase.latestAlerts,
replacements: {},
subAction: 'invokeAI',
});
});
it('returns the expected AttackDiscoveryPostRequestBody with the expected apiConfig when genAiConfig is provided', () => {
const genAiConfig = {
apiProvider: OpenAiProviderType.AzureAi,
defaultModel: '2024-02-15-preview',
};
const result = getRequestBody({
alertsIndexPattern,
anonymizationFields,
genAiConfig, // <-- genAiConfig is provided
knowledgeBase,
selectedConnector: connector, // <-- selectedConnector is provided
traceOptions,
});
expect(result).toEqual({
alertsIndexPattern,
anonymizationFields: anonymizationFields.data,
apiConfig: {
actionTypeId: connector.actionTypeId,
connectorId: connector.id,
model: genAiConfig.defaultModel,
provider: genAiConfig.apiProvider,
},
langSmithProject: undefined,
langSmithApiKey: undefined,
size: knowledgeBase.latestAlerts,
replacements: {},
subAction: 'invokeAI',
});
});
});

View file

@ -0,0 +1,171 @@
/*
* 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { getAnonymizedAlerts } from './get_anonymized_alerts';
import { mockOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_open_and_acknowledged_alerts_query_results';
import { getOpenAndAcknowledgedAlertsQuery } from '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query';
import { MIN_SIZE } from '../open_and_acknowledged_alerts/helpers';
jest.mock('../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query', () => {
const original = jest.requireActual(
'../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query'
);
return {
getOpenAndAcknowledgedAlertsQuery: jest.fn(() => original),
};
});
describe('getAnonymizedAlerts', () => {
const alertsIndexPattern = '.alerts-security.alerts-default';
const mockAnonymizationFields = [
{
id: '9f95b649-f20e-4edf-bd76-1d21ab6f8e2e',
timestamp: '2024-05-06T22:16:48.489Z',
field: '_id',
allowed: true,
anonymized: false,
createdAt: '2024-05-06T22:16:48.489Z',
namespace: 'default',
},
{
id: '22f23471-4f6a-4cec-9b2a-cf270ffb53d5',
timestamp: '2024-05-06T22:16:48.489Z',
field: 'host.name',
allowed: true,
anonymized: true,
createdAt: '2024-05-06T22:16:48.489Z',
namespace: 'default',
},
];
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const mockReplacements = {
replacement1: 'SRVMAC08',
replacement2: 'SRVWIN01',
replacement3: 'SRVWIN02',
};
const size = 10;
beforeEach(() => {
jest.clearAllMocks();
(mockEsClient.search as unknown as jest.Mock).mockResolvedValue(
mockOpenAndAcknowledgedAlertsQueryResults
);
});
it('returns an empty array when alertsIndexPattern is not provided', async () => {
const result = await getAnonymizedAlerts({
esClient: mockEsClient,
size,
});
expect(result).toEqual([]);
});
it('should return an empty array when size is not provided', async () => {
const result = await getAnonymizedAlerts({
alertsIndexPattern,
esClient: mockEsClient,
});
expect(result).toEqual([]);
});
it('should return an empty array when size is out of range', async () => {
const outOfRange = MIN_SIZE - 1;
const result = await getAnonymizedAlerts({
alertsIndexPattern,
esClient: mockEsClient,
size: outOfRange,
});
expect(result).toEqual([]);
});
it('calls getOpenAndAcknowledgedAlertsQuery with the provided anonymizationFields', async () => {
await getAnonymizedAlerts({
alertsIndexPattern,
anonymizationFields: mockAnonymizationFields,
esClient: mockEsClient,
replacements: mockReplacements,
size,
});
expect(getOpenAndAcknowledgedAlertsQuery).toHaveBeenCalledWith({
alertsIndexPattern,
anonymizationFields: mockAnonymizationFields,
size,
});
});
it('calls getOpenAndAcknowledgedAlertsQuery with empty anonymizationFields when they are NOT provided', async () => {
await getAnonymizedAlerts({
alertsIndexPattern,
esClient: mockEsClient,
replacements: mockReplacements,
size,
});
expect(getOpenAndAcknowledgedAlertsQuery).toHaveBeenCalledWith({
alertsIndexPattern,
anonymizationFields: [],
size,
});
});
it('returns the expected transformed (anonymized) raw data', async () => {
const result = await getAnonymizedAlerts({
alertsIndexPattern,
anonymizationFields: mockAnonymizationFields,
esClient: mockEsClient,
replacements: mockReplacements,
size,
});
expect(result).toEqual([
'_id,b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560\nhost.name,replacement1',
'_id,0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367\nhost.name,replacement1',
'_id,600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a\nhost.name,replacement1',
'_id,e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c\nhost.name,replacement1',
'_id,2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f\nhost.name,replacement1',
'_id,2a9f7602de8656d30dda0ddcf79e78037ac2929780e13d5b2047b3bedc40bb69\nhost.name,replacement1',
'_id,4615c3a90e8057ae5cc9b358bbbf4298e346277a2f068dda052b0b43ef6d5bbd\nhost.name,replacement1',
'_id,449322a72d3f19efbdf983935a1bdd21ebd6b9c761ce31e8b252003017d7e5db\nhost.name,replacement1',
'_id,f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014\nhost.name,replacement3',
'_id,aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4\nhost.name,replacement3',
'_id,dd9e4ea23961ccfdb7a9c760ee6bedd19a013beac3b0d38227e7ae77ba4ce515\nhost.name,replacement3',
'_id,f30d55e503b1d848b34ee57741b203d8052360dd873ea34802f3fa7a9ef34d0a\nhost.name,replacement3',
'_id,6f8cd5e8021dbb64598f2b7ec56bee21fd00d1e62d4e08905f86bf234873ee66\nhost.name,replacement3',
'_id,ce110da958fe0cf0c07599a21c68d90a64c93b7607aa27970a614c7f49598316\nhost.name,replacement3',
'_id,0866787b0027b4d908767ac16e35a1da00970c83632ba85be65f2ad371132b4f\nhost.name,replacement3',
'_id,b0fdf96721e361e1137d49a67e26d92f96b146392d7f44322bddc3d660abaef1\nhost.name,replacement3',
'_id,7b4f49f21cf141e67856d3207fb4ea069c8035b41f0ea501970694cf8bd43cbe\nhost.name,replacement3',
'_id,ea81d79104cbd442236b5bcdb7a3331de897aa4ce1523e622068038d048d0a9e\nhost.name,replacement3',
'_id,cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b\nhost.name,replacement2',
'_id,6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3\nhost.name,replacement2',
]);
});
it('calls onNewReplacements for every alert', async () => {
const onNewReplacements = jest.fn();
await getAnonymizedAlerts({
alertsIndexPattern,
anonymizationFields: mockAnonymizationFields,
esClient: mockEsClient,
onNewReplacements,
replacements: mockReplacements,
size,
});
expect(onNewReplacements).toHaveBeenCalledTimes(20); // 20 alerts in mockOpenAndAcknowledgedAlertsQueryResults
});
});

View file

@ -28,7 +28,7 @@ export const getAnonymizedAlerts = async ({
onNewReplacements?: (replacements: Replacements) => void;
replacements?: Replacements;
size?: number;
}) => {
}): Promise<string[]> => {
if (alertsIndexPattern == null || size == null || sizeIsOutOfRange(size)) {
return [];
}

View file

@ -0,0 +1,30 @@
/*
* 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 { getAttackDiscoveryPrompt } from './get_attack_discovery_prompt';
describe('getAttackDiscoveryPrompt', () => {
it('should generate the correct attack discovery prompt', () => {
const anonymizedAlerts = ['Alert 1', 'Alert 2', 'Alert 3'];
const expected = `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output.
Use context from the following open and acknowledged alerts to provide insights:
"""
Alert 1
Alert 2
Alert 3
"""
`;
const prompt = getAttackDiscoveryPrompt({ anonymizedAlerts });
expect(prompt).toEqual(expected);
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { getOutputParser } from './get_output_parser';
describe('getOutputParser', () => {
it('returns a structured output parser with the expected format instructions', () => {
const outputParser = getOutputParser();
const expected = `You must format your output as a JSON value that adheres to a given \"JSON Schema\" instance.
\"JSON Schema\" is a declarative language that allows you to annotate and validate JSON documents.
For example, the example \"JSON Schema\" instance {{\"properties\": {{\"foo\": {{\"description\": \"a list of test words\", \"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}}}, \"required\": [\"foo\"]}}}}
would match an object with one required property, \"foo\". The \"type\" property specifies \"foo\" must be an \"array\", and the \"description\" property semantically describes it as \"a list of test words\". The items within \"foo\" must be strings.
Thus, the object {{\"foo\": [\"bar\", \"baz\"]}} is a well-formatted instance of this example \"JSON Schema\". The object {{\"properties\": {{\"foo\": [\"bar\", \"baz\"]}}}} is not well-formatted.
Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas!
Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock:
\`\`\`json
{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"alertIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"The alert IDs that the insight is based on.\"},\"detailsMarkdown\":{\"type\":\"string\",\"description\":\"A detailed insight with markdown that always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"},\"entitySummaryMarkdown\":{\"type\":\"string\",\"description\":\"A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"mitreAttackTactics\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"An array of MITRE ATT&CK tactic for the insight, using one of the following values: Reconnaissance,Initial Access,Execution,Persistence,Privilege Escalation,Discovery,Lateral Movement,Command and Control,Exfiltration\"},\"summaryMarkdown\":{\"type\":\"string\",\"description\":\"A markdown summary of insight, using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"title\":{\"type\":\"string\",\"description\":\"A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.\"}},\"required\":[\"alertIds\",\"detailsMarkdown\",\"summaryMarkdown\",\"title\"],\"additionalProperties\":false},\"description\":\"Insights with markdown that always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\",\"$schema\":\"http://json-schema.org/draft-07/schema#\"}
\`\`\`
`;
expect(outputParser.getFormatInstructions()).toEqual(expected);
});
});

View file

@ -1,21 +0,0 @@
/*
* 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 type { Replacements } from '@kbn/elastic-assistant-common';
export const getReplacementsRecords = (
replacements: Array<{ value: string; uuid: string }>
): Replacements =>
replacements.reduce<Record<string, string>>(
(acc, { value, uuid }) => ({ ...acc, [uuid]: value }),
{}
);
export const getReplacementsArray = (
replacements: Replacements
): Array<{ value: string; uuid: string }> =>
Object.entries(replacements).map(([uuid, value]) => ({ uuid, value }));