Skip to content

Commit

Permalink
[Security Solution] Add quick job installation to anomalies table (#1…
Browse files Browse the repository at this point in the history
…42861)

* Add run job button
  • Loading branch information
machadoum authored Oct 20, 2022
1 parent 16d8b9f commit 2de7ddc
Show file tree
Hide file tree
Showing 21 changed files with 512 additions and 376 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,26 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// import React from 'react';
import type { RenderResult } from '@testing-library/react-hooks';
import { act, renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../../mock';
import type { Refetch } from '../../../store/inputs/model';
import type { AnomaliesCount } from './use_anomalies_search';
import { useNotableAnomaliesSearch, AnomalyJobStatus, AnomalyEntity } from './use_anomalies_search';
import { useNotableAnomaliesSearch, AnomalyEntity } from './use_anomalies_search';

const jobId = 'auth_rare_source_ip_for_a_user';
const from = 'now-24h';
const to = 'now';
const JOBS = [{ id: jobId, jobState: 'started', datafeedState: 'started' }];
const job = { id: jobId, jobState: 'started', datafeedState: 'started' };
const JOBS = [job];
const useSecurityJobsRefetch = jest.fn();

const mockuseInstalledSecurityJobs = jest.fn().mockReturnValue({
const mockUseSecurityJobs = jest.fn().mockReturnValue({
loading: false,
isMlUser: true,
isMlAdmin: true,
jobs: JOBS,
refetch: useSecurityJobsRefetch,
});

jest.mock('../hooks/use_installed_security_jobs', () => ({
useInstalledSecurityJobs: () => mockuseInstalledSecurityJobs(),
jest.mock('../../ml_popover/hooks/use_security_jobs', () => ({
useSecurityJobs: () => mockUseSecurityJobs(),
}));

const mockAddToastError = jest.fn();
Expand Down Expand Up @@ -72,32 +71,21 @@ describe('useNotableAnomaliesSearch', () => {
expect(mockNotableAnomaliesSearch).not.toHaveBeenCalled();
});

it('refetch calls notableAnomaliesSearch', async () => {
let renderResult: RenderResult<{
isLoading: boolean;
data: AnomaliesCount[];
refetch: Refetch;
}>;

// first notableAnomaliesSearch call
it('refetch calls useSecurityJobs().refetch', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() => useNotableAnomaliesSearch({ skip: false, from, to }),
{
wrapper: TestProviders,
}
);
renderResult = result;

await waitForNextUpdate();
});

await act(async () => {
mockNotableAnomaliesSearch.mockClear(); // clear the first notableAnomaliesSearch call
await renderResult.current.refetch();
result.current.refetch();
});

expect(mockNotableAnomaliesSearch).toHaveBeenCalled();
expect(useSecurityJobsRefetch).toHaveBeenCalled();
});

it('returns formated data', async () => {
Expand All @@ -119,21 +107,37 @@ describe('useNotableAnomaliesSearch', () => {
expect.arrayContaining([
{
count: 99,
jobId,
name: jobId,
status: AnomalyJobStatus.enabled,
job,
entity: AnomalyEntity.Host,
},
])
);
});
});

it('does not throw error when aggregations is undefined', async () => {
await act(async () => {
mockNotableAnomaliesSearch.mockResolvedValue({});
const { waitForNextUpdate } = renderHook(
() => useNotableAnomaliesSearch({ skip: false, from, to }),
{
wrapper: TestProviders,
}
);
await waitForNextUpdate();
await waitForNextUpdate();

expect(mockAddToastError).not.toBeCalled();
});
});

it('returns uninstalled jobs', async () => {
mockuseInstalledSecurityJobs.mockReturnValue({
mockUseSecurityJobs.mockReturnValue({
loading: false,
isMlUser: true,
isMlAdmin: true,
jobs: [],
refetch: useSecurityJobsRefetch,
});

await act(async () => {
Expand All @@ -153,9 +157,8 @@ describe('useNotableAnomaliesSearch', () => {
expect.arrayContaining([
{
count: 0,
jobId: undefined,
name: jobId,
status: AnomalyJobStatus.uninstalled,
name: job.id,
job: undefined,
entity: AnomalyEntity.Host,
},
])
Expand All @@ -169,16 +172,18 @@ describe('useNotableAnomaliesSearch', () => {
mockNotableAnomaliesSearch.mockResolvedValue({
aggregations: { number_of_anomalies: { buckets: [jobCount] } },
});
mockuseInstalledSecurityJobs.mockReturnValue({

const customJob = {
id: customJobId,
jobState: 'started',
datafeedState: 'started',
};

mockUseSecurityJobs.mockReturnValue({
loading: false,
isMlUser: true,
jobs: [
{
id: customJobId,
jobState: 'started',
datafeedState: 'started',
},
],
isMlAdmin: true,
jobs: [customJob],
refetch: useSecurityJobsRefetch,
});

await act(async () => {
Expand All @@ -196,9 +201,8 @@ describe('useNotableAnomaliesSearch', () => {
expect.arrayContaining([
{
count: 99,
jobId: customJobId,
name: jobId,
status: AnomalyJobStatus.enabled,
name: job.id,
job: customJob,
entity: AnomalyEntity.Host,
},
])
Expand All @@ -209,6 +213,12 @@ describe('useNotableAnomaliesSearch', () => {
it('returns the most recent job when there are multiple jobs matching one notable job id`', async () => {
const mostRecentJobId = `mostRecent_${jobId}`;
const leastRecentJobId = `leastRecent_${jobId}`;
const mostRecentJob = {
id: mostRecentJobId,
jobState: 'started',
datafeedState: 'started',
latestTimestampSortValue: 1661731200000, // 2022-08-29
};

mockNotableAnomaliesSearch.mockResolvedValue({
aggregations: {
Expand All @@ -221,23 +231,19 @@ describe('useNotableAnomaliesSearch', () => {
},
});

mockuseInstalledSecurityJobs.mockReturnValue({
mockUseSecurityJobs.mockReturnValue({
loading: false,
isMlUser: true,
isMlAdmin: true,
jobs: [
{
id: leastRecentJobId,
jobState: 'started',
datafeedState: 'started',
latestTimestampSortValue: 1661644800000, // 2022-08-28
},
{
id: mostRecentJobId,
jobState: 'started',
datafeedState: 'started',
latestTimestampSortValue: 1661731200000, // 2022-08-29
},
mostRecentJob,
],
refetch: useSecurityJobsRefetch,
});

await act(async () => {
Expand All @@ -254,9 +260,8 @@ describe('useNotableAnomaliesSearch', () => {
expect.arrayContaining([
{
count: 99,
jobId: mostRecentJobId,
name: jobId,
status: AnomalyJobStatus.enabled,
job: mostRecentJob,
entity: AnomalyEntity.Host,
},
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,20 @@
* 2.0.
*/

import { useState, useEffect, useMemo, useRef } from 'react';
import { filter, head, noop, orderBy, pipe, has } from 'lodash/fp';
import type { MlSummaryJob } from '@kbn/ml-plugin/common';
import { useState, useEffect, useMemo } from 'react';
import { filter, head, orderBy, pipe, has } from 'lodash/fp';

import { DEFAULT_ANOMALY_SCORE } from '../../../../../common/constants';
import * as i18n from './translations';
import { useUiSetting$ } from '../../../lib/kibana';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useInstalledSecurityJobs } from '../hooks/use_installed_security_jobs';
import { notableAnomaliesSearch } from '../api/anomalies_search';
import type { NotableAnomaliesJobId } from '../../../../overview/components/entity_analytics/anomalies/config';
import { NOTABLE_ANOMALIES_IDS } from '../../../../overview/components/entity_analytics/anomalies/config';
import { getAggregatedAnomaliesQuery } from '../../../../overview/components/entity_analytics/anomalies/query';
import type { inputsModel } from '../../../store';
import { isJobFailed, isJobStarted } from '../../../../../common/machine_learning/helpers';

export enum AnomalyJobStatus {
'enabled',
'disabled',
'uninstalled',
'failed',
}
import { useSecurityJobs } from '../../ml_popover/hooks/use_security_jobs';
import type { SecurityJob } from '../../ml_popover/types';

export const enum AnomalyEntity {
User,
Expand All @@ -35,10 +27,9 @@ export const enum AnomalyEntity {

export interface AnomaliesCount {
name: NotableAnomaliesJobId;
jobId?: string;
count: number;
status: AnomalyJobStatus;
entity: AnomalyEntity;
job?: SecurityJob;
}

interface UseNotableAnomaliesSearchProps {
Expand All @@ -57,19 +48,20 @@ export const useNotableAnomaliesSearch = ({
refetch: inputsModel.Refetch;
} => {
const [data, setData] = useState<AnomaliesCount[]>(formatResultData([], []));
const refetch = useRef<inputsModel.Refetch>(noop);

const {
loading: installedJobsLoading,
isMlUser,
jobs: installedSecurityJobs,
} = useInstalledSecurityJobs();
loading: jobsLoading,
isMlAdmin: isMlUser,
jobs: securityJobs,
refetch: refetchJobs,
} = useSecurityJobs();

const [loading, setLoading] = useState(true);
const { addError } = useAppToasts();
const [anomalyScoreThreshold] = useUiSetting$<number>(DEFAULT_ANOMALY_SCORE);

const { notableAnomaliesJobs, query } = useMemo(() => {
const newNotableAnomaliesJobs = installedSecurityJobs.filter(({ id }) =>
const newNotableAnomaliesJobs = securityJobs.filter(({ id }) =>
NOTABLE_ANOMALIES_IDS.some((notableJobId) => matchJobId(id, notableJobId))
);

Expand All @@ -84,7 +76,7 @@ export const useNotableAnomaliesSearch = ({
query: newQuery,
notableAnomaliesJobs: newNotableAnomaliesJobs,
};
}, [installedSecurityJobs, anomalyScoreThreshold, from, to]);
}, [securityJobs, anomalyScoreThreshold, from, to]);

useEffect(() => {
let isSubscribed = true;
Expand All @@ -102,15 +94,15 @@ export const useNotableAnomaliesSearch = ({
try {
const response = await notableAnomaliesSearch(
{
jobIds: notableAnomaliesJobs.map(({ id }) => id),
jobIds: notableAnomaliesJobs.filter((job) => job.isInstalled).map(({ id }) => id),
query,
},
abortCtrl.signal
);

if (isSubscribed) {
setLoading(false);
const buckets = response.aggregations.number_of_anomalies.buckets;
const buckets = response.aggregations?.number_of_anomalies.buckets ?? [];
setData(formatResultData(buckets, notableAnomaliesJobs));
}
} catch (error) {
Expand All @@ -122,40 +114,22 @@ export const useNotableAnomaliesSearch = ({
}

fetchAnomaliesSearch();
refetch.current = fetchAnomaliesSearch;

return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [skip, isMlUser, addError, query, notableAnomaliesJobs]);
}, [skip, isMlUser, addError, query, notableAnomaliesJobs, refetchJobs]);

return { isLoading: loading || installedJobsLoading, data, refetch: refetch.current };
};

const getMLJobStatus = (
notableJobId: NotableAnomaliesJobId,
job: MlSummaryJob | undefined,
notableAnomaliesJobs: MlSummaryJob[]
) => {
if (job) {
if (isJobStarted(job.jobState, job.datafeedState)) {
return AnomalyJobStatus.enabled;
}
if (isJobFailed(job.jobState, job.datafeedState)) {
return AnomalyJobStatus.failed;
}
}
return notableAnomaliesJobs.some(({ id }) => matchJobId(id, notableJobId))
? AnomalyJobStatus.disabled
: AnomalyJobStatus.uninstalled;
return { isLoading: loading || jobsLoading, data, refetch: refetchJobs };
};

function formatResultData(
buckets: Array<{
key: string;
doc_count: number;
}>,
notableAnomaliesJobs: MlSummaryJob[]
notableAnomaliesJobs: SecurityJob[]
): AnomaliesCount[] {
return NOTABLE_ANOMALIES_IDS.map((notableJobId) => {
const job = findJobWithId(notableJobId)(notableAnomaliesJobs);
Expand All @@ -164,10 +138,9 @@ function formatResultData(

return {
name: notableJobId,
jobId: job?.id,
count: bucket?.doc_count ?? 0,
status: getMLJobStatus(notableJobId, job, notableAnomaliesJobs),
entity: hasUserName ? AnomalyEntity.User : AnomalyEntity.Host,
job,
};
});
}
Expand All @@ -183,8 +156,8 @@ const matchJobId = (jobId: string, notableJobId: NotableAnomaliesJobId) =>
* When multiple jobs match a notable job id, it returns the most recent one.
*/
const findJobWithId = (notableJobId: NotableAnomaliesJobId) =>
pipe<MlSummaryJob[][], MlSummaryJob[], MlSummaryJob[], MlSummaryJob | undefined>(
filter<MlSummaryJob>(({ id }) => matchJobId(id, notableJobId)),
orderBy<MlSummaryJob>('latestTimestampSortValue', 'desc'),
pipe<SecurityJob[][], SecurityJob[], SecurityJob[], SecurityJob | undefined>(
filter<SecurityJob>(({ id }) => matchJobId(id, notableJobId)),
orderBy<SecurityJob>('latestTimestampSortValue', 'desc'),
head
);
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface Body {
}

export interface AnomaliesSearchResponse {
aggregations: {
aggregations?: {
number_of_anomalies: {
buckets: Array<{
key: NotableAnomaliesJobId;
Expand Down
Loading

0 comments on commit 2de7ddc

Please sign in to comment.