Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution] Populate alert status auditing fields #171589

Merged
merged 15 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
ALERT_SUPPRESSION_VALUE,
ALERT_SYSTEM_STATUS,
ALERT_WORKFLOW_REASON,
ALERT_WORKFLOW_STATUS_UPDATED_AT,
ALERT_WORKFLOW_USER,
ECS_VERSION,
} from '@kbn/rule-data-utils';
Expand Down Expand Up @@ -173,6 +174,11 @@ export const legacyAlertFieldMap = {
array: false,
required: false,
},
[ALERT_WORKFLOW_STATUS_UPDATED_AT]: {
type: 'date',
array: false,
required: false,
},
// get these from ecs field map when available
[ECS_VERSION]: {
type: 'keyword',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const LegacyAlertOptional = rt.partial({
'kibana.alert.suppression.terms.value': schemaStringArray,
'kibana.alert.system_status': schemaString,
'kibana.alert.workflow_reason': schemaString,
'kibana.alert.workflow_status_updated_at': schemaDate,
'kibana.alert.workflow_user': schemaString,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ const SecurityAlertOptional = rt.partial({
'kibana.alert.workflow_assignee_ids': schemaStringArray,
'kibana.alert.workflow_reason': schemaString,
'kibana.alert.workflow_status': schemaString,
'kibana.alert.workflow_status_updated_at': schemaDate,
'kibana.alert.workflow_tags': schemaStringArray,
'kibana.alert.workflow_user': schemaString,
'kibana.version': schemaString,
Expand Down
2 changes: 2 additions & 0 deletions packages/kbn-rule-data-utils/src/legacy_alerts_as_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const ALERT_SUPPRESSION_DOCS_COUNT = `${ALERT_SUPPRESSION_META}.docs_count` as c
const ALERT_SYSTEM_STATUS = `${ALERT_NAMESPACE}.system_status` as const;
const ALERT_WORKFLOW_REASON = `${ALERT_NAMESPACE}.workflow_reason` as const;
const ALERT_WORKFLOW_USER = `${ALERT_NAMESPACE}.workflow_user` as const;
const ALERT_WORKFLOW_STATUS_UPDATED_AT = `${ALERT_NAMESPACE}.workflow_status_updated_at` as const;

export {
ALERT_RISK_SCORE,
Expand Down Expand Up @@ -77,6 +78,7 @@ export {
ALERT_SYSTEM_STATUS,
ALERT_WORKFLOW_REASON,
ALERT_WORKFLOW_USER,
ALERT_WORKFLOW_STATUS_UPDATED_AT,
ECS_VERSION,
EVENT_ACTION,
EVENT_KIND,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ describe('mappingFromFieldMap', () => {
},
system_status: { type: 'keyword' },
workflow_reason: { type: 'keyword' },
workflow_status_updated_at: { type: 'date' },
workflow_user: { type: 'keyword' },
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ it('matches snapshot', () => {
"required": false,
"type": "keyword",
},
"kibana.alert.workflow_status_updated_at": Object {
"array": false,
"required": false,
"type": "date",
},
"kibana.alert.workflow_tags": Object {
"array": true,
"required": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { render } from '@testing-library/react';
import { UsersAvatarsPanel } from './users_avatars_panel';

import { TestProviders } from '../../mock';
import { mockUserProfiles } from '../assignees/mocks';
import { mockUserProfiles } from './mock';
import {
USERS_AVATARS_COUNT_BADGE_TEST_ID,
USERS_AVATARS_PANEL_TEST_ID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ABOUT_SECTION_TEST_ID } from './test_ids';
import { Description } from './description';
import { Reason } from './reason';
import { MitreAttack } from './mitre_attack';
import { AlertStatus } from './alert_status';

export interface AboutSectionProps {
/**
Expand All @@ -40,8 +41,8 @@ export const AboutSection: VFC<AboutSectionProps> = ({ expanded = true }) => {
<Description />
<EuiSpacer size="m" />
<Reason />
<EuiSpacer size="m" />
<MitreAttack />
<AlertStatus />
</ExpandableSection>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { act, render } from '@testing-library/react';
import { AlertStatus } from './alert_status';
import { RightPanelContext } from '../context';
import { WORKFLOW_STATUS_DETAILS_TEST_ID, WORKFLOW_STATUS_TITLE_TEST_ID } from './test_ids';
import { mockSearchHit } from '../../shared/mocks/mock_search_hit';
import { TestProviders } from '../../../../common/mock';
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';

jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles');

const renderAlertStatus = (contextValue: RightPanelContext) =>
render(
<TestProviders>
<RightPanelContext.Provider value={contextValue}>
<AlertStatus />
</RightPanelContext.Provider>
</TestProviders>
);

const mockUserProfiles = [
{ uid: 'user-id-1', enabled: true, user: { username: 'user1', full_name: 'User 1' }, data: {} },
];

describe('<AlertStatus />', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render alert status history information', async () => {
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
isLoading: false,
data: mockUserProfiles,
});
const contextValue = {
searchHit: {
...mockSearchHit,
fields: {
'kibana.alert.workflow_user': 'user-id-1',
'kibana.alert.workflow_status_updated_at': '2023-11-01T22:33:26.893Z',
},
},
} as unknown as RightPanelContext;

const { getByTestId } = renderAlertStatus(contextValue);

await act(async () => {
expect(getByTestId(WORKFLOW_STATUS_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(WORKFLOW_STATUS_DETAILS_TEST_ID)).toBeInTheDocument();
});
});

it('should render empty component if missing workflow_user value', async () => {
const contextValue = {
searchHit: {
some_field: 'some_value',
},
} as unknown as RightPanelContext;

const { container } = renderAlertStatus(contextValue);

await act(async () => {
expect(container).toBeEmptyDOMElement();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { getUserDisplayName } from '@kbn/user-profile-components';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FC } from 'react';
import React from 'react';
import { WORKFLOW_STATUS_DETAILS_TEST_ID, WORKFLOW_STATUS_TITLE_TEST_ID } from './test_ids';
import { useRightPanelContext } from '../context';
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';

/**
* Displays info about who last updated the alert's workflow status and when.
*/
export const AlertStatus: FC = () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

can you please add a short documentation here, just to be consistent with all the other components in the document_details folder?

const { searchHit } = useRightPanelContext();
const statusUpdatedBy = searchHit.fields?.['kibana.alert.workflow_user'] as string;
const statusUpdatedAt = searchHit.fields?.['kibana.alert.workflow_status_updated_at'];
Comment on lines +23 to +24
Copy link
Contributor

Choose a reason for hiding this comment

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

any reason for not using getFieldsData along side our x-pack/plugins/security_solution/public/flyout/document_details/shared/utils.tsx like we use in the rest of the document_details flyout code?

The getFieldsData is available from the useRightPanelContext you're already using.

It would be

const { getFieldsData } = useRightPanelContext();

const statusUpdatedBy = getField(getFieldsData('kibana.alert.workflow_user'));
const statusUpdatedAt = getField(getFieldsData('kibana.alert.workflow_status_updated_at'));

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I figured getting the raw data from the searchHit was simple enough, I can change it though


const result = useBulkGetUserProfiles({ uids: new Set(statusUpdatedBy) });
const user = result.data?.[0]?.user;

if (!statusUpdatedBy || !statusUpdatedAt || result.isLoading || user == null) {
return null;
}

return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiSpacer size="m" />
<EuiFlexItem data-test-subj={WORKFLOW_STATUS_TITLE_TEST_ID}>
<EuiTitle size="xxs">
<h5>
<FormattedMessage
id="xpack.securitySolution.flyout.right.about.status.statusHistoryTitle"
defaultMessage="Last alert status change"
/>
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem data-test-subj={WORKFLOW_STATUS_DETAILS_TEST_ID}>
<FormattedMessage
id="xpack.securitySolution.flyout.right.about.status.statusHistoryDetails"
defaultMessage="Alert status updated by {user} on {date}"
values={{
user: getUserDisplayName(user),
date: <PreferenceFormattedDate value={new Date(statusUpdatedAt)} />,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

AlertStatus.displayName = 'AlertStatus';
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import type { FC } from 'react';
import React, { useMemo } from 'react';
import { MITRE_ATTACK_DETAILS_TEST_ID, MITRE_ATTACK_TITLE_TEST_ID } from './test_ids';
Expand All @@ -23,6 +23,7 @@ export const MitreAttack: FC = () => {

return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiSpacer size="m" />
<EuiFlexItem data-test-subj={MITRE_ATTACK_TITLE_TEST_ID}>
<EuiTitle size="xxs">
<h5>{threatDetails[0].title}</h5>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const MITRE_ATTACK_TEST_ID = `${PREFIX}MitreAttack` as const;
export const MITRE_ATTACK_TITLE_TEST_ID = `${MITRE_ATTACK_TEST_ID}Title` as const;
export const MITRE_ATTACK_DETAILS_TEST_ID = `${MITRE_ATTACK_TEST_ID}Details` as const;

export const WORKFLOW_STATUS_TEST_ID = `${PREFIX}WorkflowStatus` as const;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit but this one doesn't need to be exported

export const WORKFLOW_STATUS_TITLE_TEST_ID = `${WORKFLOW_STATUS_TEST_ID}Title` as const;
export const WORKFLOW_STATUS_DETAILS_TEST_ID = `${WORKFLOW_STATUS_TEST_ID}Details` as const;

/* Investigation section */

export const INVESTIGATION_SECTION_TEST_ID = `${PREFIX}InvestigationSection` as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* 2.0.
*/

import { coreMock, loggingSystemMock } from '@kbn/core/server/mocks';

import { DETECTION_ENGINE_SIGNALS_STATUS_URL } from '../../../../../common/constants';
import {
getSetSignalStatusByIdsRequest,
Expand All @@ -15,17 +17,25 @@ import {
getSuccessfulSignalUpdateResponse,
} from '../__mocks__/request_responses';
import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import type { SetupPlugins } from '../../../../plugin';
import { createMockTelemetryEventsSender } from '../../../telemetry/__mocks__';
import { setSignalsStatusRoute } from './open_close_signals_route';
import { loggingSystemMock } from '@kbn/core/server/mocks';

describe('set signal status', () => {
let server: ReturnType<typeof serverMock.create>;
let { context } = requestContextMock.createTools();
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
let mockCore: ReturnType<typeof coreMock.createSetup>;

beforeEach(() => {
mockCore = coreMock.createSetup({
pluginStartDeps: {
security: {
authc: {
getCurrentUser: jest.fn().mockReturnValue({ user: { username: 'my-username' } }),
},
},
},
});
server = serverMock.create();
logger = loggingSystemMock.createLogger();
({ context } = requestContextMock.createTools());
Expand All @@ -34,12 +44,7 @@ describe('set signal status', () => {
getSuccessfulSignalUpdateResponse()
);
const telemetrySenderMock = createMockTelemetryEventsSender();
const securityMock = {
authc: {
getCurrentUser: jest.fn().mockReturnValue({ user: { username: 'my-username' } }),
},
} as unknown as SetupPlugins['security'];
setSignalsStatusRoute(server.router, logger, securityMock, telemetrySenderMock);
setSignalsStatusRoute(server.router, logger, telemetrySenderMock, mockCore.getStartServices);
});

describe('status on signal', () => {
Expand Down
Loading
Loading