Skip to content

Commit

Permalink
[Security Solution] [Cases] Introduce case observables (phase 0 & 1) (e…
Browse files Browse the repository at this point in the history
…lastic#190237)

## Summary

### Introducting Case Observables - _phases 0 and 1_

This pull request introduces case observables to Kibana, enhancing the
platform's case management capabilities. It adds support for capturing
and displaying observables (e.g., IP addresses, URLs, file hashes)
linked to cases. The feature integrates with the Cases UI, allowing
users to easily associate observables with cases for better tracking and
analysis in incident response workflows. This improves investigative
efficiency by correlating observables across multiple cases.

#### Requirements:

https://docs.google.com/document/d/12hZTpyn0eXy3Xnq8qLBd6_sJxBhNZoI7vXztxWHhUds/edit#heading=h.srf6mb8ifiad

#### Design document:
https://docs.google.com/document/d/1MeDLl6OEWast1RC1M3_hQXnRCd8frrXdGkFnypIYKJQ/edit#heading=h.kb5lrp2j62id

Notable Cases sections are added in this pr:

**1. Observables section in the case view, allowing for adding and
listing up to 10 observables for the case**


![image](https://github.com/user-attachments/assets/f517803d-a6a3-4428-b3e3-478e70c60050)

**2. Similar cases view for every case, allowing for similar case
discovery**


![image](https://github.com/user-attachments/assets/388fddfb-9533-4f0d-aa8b-f5601e5323e0)

**3. Observable types management view in Cases settings**


![image](https://github.com/user-attachments/assets/2d76f8be-c234-4f24-a419-da54228fb111)

Original issue:

elastic#180360

Things skipped for now from MVP:
- [ ] Allow users to manually create observables from the cases alerts
table using the table actions (Phase 1)
- [ ] Allow users to manually create observables of type “hash” from the
files table using the table actions (Phase 1)

---------

Co-authored-by: Christos Nasikas <[email protected]>
Co-authored-by: kibanamachine <[email protected]>
Co-authored-by: Christos Nasikas <[email protected]>
  • Loading branch information
4 people authored Dec 23, 2024
1 parent 8e0561a commit 3083706
Show file tree
Hide file tree
Showing 155 changed files with 7,434 additions and 92 deletions.
3 changes: 3 additions & 0 deletions packages/kbn-check-mappings-update-cli/current_fields.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@
"external_service.pushed_by.full_name",
"external_service.pushed_by.profile_uid",
"external_service.pushed_by.username",
"observables",
"observables.typeKey",
"observables.value",
"owner",
"settings",
"settings.syncAlerts",
Expand Down
11 changes: 11 additions & 0 deletions packages/kbn-check-mappings-update-cli/current_mappings.json
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,17 @@
}
}
},
"observables": {
"properties": {
"typeKey": {
"type": "keyword"
},
"value": {
"type": "keyword"
}
},
"type": "nested"
},
"owner": {
"type": "keyword"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"canvas-element": "cdedc2123eb8a1506b87a56b0bcce60f4ec08bc8",
"canvas-workpad": "9d82aafb19586b119e5c9382f938abe28c26ca5c",
"canvas-workpad-template": "c077b0087346776bb3542b51e1385d172cb24179",
"cases": "5433a9f1277f8f17bbc4fd20d33b1fc6d997931e",
"cases": "91771732e2e488e4c1b1ac468057925d1c6b32b5",
"cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25",
"cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf",
"cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25",
Expand Down
26 changes: 26 additions & 0 deletions x-pack/plugins/cases/common/api/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
INTERNAL_DELETE_FILE_ATTACHMENTS_URL,
CASE_FIND_ATTACHMENTS_URL,
INTERNAL_PUT_CUSTOM_FIELDS_URL,
INTERNAL_CASE_OBSERVABLES_URL,
INTERNAL_CASE_OBSERVABLES_PATCH_URL,
INTERNAL_CASE_SIMILAR_CASES_URL,
INTERNAL_CASE_OBSERVABLES_DELETE_URL,
} from '../constants';

export const getCaseDetailsUrl = (id: string): string => {
Expand Down Expand Up @@ -90,3 +94,25 @@ export const getCustomFieldReplaceUrl = (caseId: string, customFieldId: string):
customFieldId
);
};

export const getCaseCreateObservableUrl = (id: string): string => {
return INTERNAL_CASE_OBSERVABLES_URL.replace('{case_id}', id);
};

export const getCaseUpdateObservableUrl = (id: string, observableId: string): string => {
return INTERNAL_CASE_OBSERVABLES_PATCH_URL.replace('{case_id}', id).replace(
'{observable_id}',
observableId
);
};

export const getCaseDeleteObservableUrl = (id: string, observableId: string): string => {
return INTERNAL_CASE_OBSERVABLES_DELETE_URL.replace('{case_id}', id).replace(
'{observable_id}',
observableId
);
};

export const getCaseSimilarCasesUrl = (caseId: string) => {
return INTERNAL_CASE_SIMILAR_CASES_URL.replace('{case_id}', caseId);
};
69 changes: 69 additions & 0 deletions x-pack/plugins/cases/common/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,14 @@ export const INTERNAL_DELETE_FILE_ATTACHMENTS_URL =
export const INTERNAL_GET_CASE_CATEGORIES_URL = `${CASES_INTERNAL_URL}/categories` as const;
export const INTERNAL_CASE_METRICS_URL = `${CASES_INTERNAL_URL}/metrics` as const;
export const INTERNAL_CASE_METRICS_DETAILS_URL = `${CASES_INTERNAL_URL}/metrics/{case_id}` as const;
export const INTERNAL_CASE_SIMILAR_CASES_URL = `${CASES_INTERNAL_URL}/{case_id}/_similar` as const;
export const INTERNAL_PUT_CUSTOM_FIELDS_URL = `${CASES_INTERNAL_URL}/{case_id}/custom_fields/{custom_field_id}`;
export const INTERNAL_CASE_OBSERVABLES_URL = `${CASES_INTERNAL_URL}/{case_id}/observables` as const;
export const INTERNAL_CASE_OBSERVABLES_PATCH_URL =
`${INTERNAL_CASE_OBSERVABLES_URL}/{observable_id}` as const;
export const INTERNAL_CASE_OBSERVABLES_DELETE_URL =
`${INTERNAL_CASE_OBSERVABLES_URL}/{observable_id}` as const;

/**
* Action routes
*/
Expand Down Expand Up @@ -142,6 +149,7 @@ export const MAX_TEMPLATES_LENGTH = 10 as const;
export const MAX_TEMPLATE_TAG_LENGTH = 50 as const;
export const MAX_TAGS_PER_TEMPLATE = 10 as const;
export const MAX_FILENAME_LENGTH = 160 as const;
export const MAX_CUSTOM_OBSERVABLE_TYPES_LABEL_LENGTH = 50 as const;

/**
* Cases features
Expand Down Expand Up @@ -204,6 +212,7 @@ export const DEFAULT_USER_SIZE = 10;
export const MAX_ASSIGNEES_PER_CASE = 10;
export const NO_ASSIGNEES_FILTERING_KEYWORD = 'none';
export const KIBANA_SYSTEM_USERNAME = 'elastic/kibana';
export const MAX_OBSERVABLES_PER_CASE = 50;

/**
* Delays
Expand Down Expand Up @@ -262,3 +271,63 @@ export const CASES_CONNECTOR_TIME_WINDOW_REGEX = '^[1-9][0-9]*[d,w]$';
* operation continues, otherwise we throw a 403.
*/
export const OWNER_FIELD = 'owner';

export const MAX_OBSERVABLE_TYPE_KEY_LENGTH = 36;

export const MAX_OBSERVABLE_TYPE_LABEL_LENGTH = 50;

export const MAX_CUSTOM_OBSERVABLE_TYPES = 10;

export const OBSERVABLE_TYPE_EMAIL = {
label: 'Email',
key: 'observable-type-email',
} as const;

export const OBSERVABLE_TYPE_DOMAIN = {
label: 'Domain',
key: 'observable-type-domain',
} as const;

export const OBSERVABLE_TYPE_IPV4 = {
label: 'IPv4',
key: 'observable-type-ipv4',
} as const;

export const OBSERVABLE_TYPE_IPV6 = {
label: 'IPv6',
key: 'observable-type-ipv6',
} as const;

export const OBSERVABLE_TYPE_URL = {
label: 'URL',
key: 'observable-type-url',
} as const;

/**
* Exporting an array of built-in observable types for use in the application
*/
export const OBSERVABLE_TYPES_BUILTIN = [
OBSERVABLE_TYPE_IPV4,
OBSERVABLE_TYPE_IPV6,
OBSERVABLE_TYPE_URL,
{
label: 'Hostname',
key: 'observable-type-hostname',
},
{
label: 'File hash',
key: 'observable-type-file-hash',
},
{
label: 'File path',
key: 'observable-type-file-path',
},
{
...OBSERVABLE_TYPE_EMAIL,
},
{
...OBSERVABLE_TYPE_DOMAIN,
},
];

export const OBSERVABLE_TYPES_BUILTIN_KEYS = OBSERVABLE_TYPES_BUILTIN.map(({ key }) => key);
2 changes: 2 additions & 0 deletions x-pack/plugins/cases/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ export enum CASE_VIEW_PAGE_TABS {
ALERTS = 'alerts',
ACTIVITY = 'activity',
FILES = 'files',
OBSERVABLES = 'observables',
SIMILAR_CASES = 'similar_cases',
}
10 changes: 10 additions & 0 deletions x-pack/plugins/cases/common/types/api/case/v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ const basicCase: Case = {
value: 3,
},
],
observables: [
{
value: 'test',
typeKey: '9b557398-0289-4e00-b696-5b277608789c',
id: 'df927ab8-54ed-47d6-be07-9948c255c097',
createdAt: '2024-11-14',
updatedAt: '2024-11-14',
description: null,
},
],
};

describe('CasePostRequestRt', () => {
Expand Down
17 changes: 16 additions & 1 deletion x-pack/plugins/cases/common/types/api/case/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
CasesRt,
CaseStatusRt,
RelatedCaseRt,
SimilarCaseRt,
} from '../../domain/case/v1';
import { CaseConnectorRt } from '../../domain/connector/v1';
import { CaseUserProfileRt, UserRt } from '../../domain/user/v1';
Expand Down Expand Up @@ -394,6 +395,13 @@ export const CasesFindResponseRt = rt.intersection([
CasesStatusResponseRt,
]);

export const CasesSimilarResponseRt = rt.strict({
cases: rt.array(SimilarCaseRt),
page: rt.number,
per_page: rt.number,
total: rt.number,
});

/**
* Delete cases
*/
Expand Down Expand Up @@ -452,7 +460,10 @@ export const CasePatchRequestRt = rt.intersection([
/**
* The saved object ID and version
*/
rt.strict({ id: rt.string, version: rt.string }),
rt.strict({
id: rt.string,
version: rt.string,
}),
]);

export const CasesPatchRequestRt = rt.strict({
Expand Down Expand Up @@ -519,6 +530,8 @@ export const CasesByAlertIDRequestRt = rt.exact(

export const GetRelatedCasesByAlertResponseRt = rt.array(RelatedCaseRt);

export const SimilarCasesSearchRequestRt = paginationSchema({ maxPerPage: MAX_CASES_PER_PAGE });

export type CasePostRequest = rt.TypeOf<typeof CasePostRequestRt>;
export type CaseResolveResponse = rt.TypeOf<typeof CaseResolveResponseRt>;
export type CasesDeleteRequest = rt.TypeOf<typeof CasesDeleteRequestRt>;
Expand All @@ -542,3 +555,5 @@ export type CaseRequestCustomFields = rt.TypeOf<typeof CaseRequestCustomFieldsRt
export type CaseRequestCustomField = rt.TypeOf<typeof CustomFieldRt>;
export type BulkCreateCasesRequest = rt.TypeOf<typeof BulkCreateCasesRequestRt>;
export type BulkCreateCasesResponse = rt.TypeOf<typeof BulkCreateCasesResponseRt>;
export type SimilarCasesSearchRequest = rt.TypeOf<typeof SimilarCasesSearchRequestRt>;
export type CasesSimilarResponse = rt.TypeOf<typeof CasesSimilarResponseRt>;
121 changes: 121 additions & 0 deletions x-pack/plugins/cases/common/types/api/configure/v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import {
MAX_CUSTOM_FIELD_KEY_LENGTH,
MAX_CUSTOM_FIELD_LABEL_LENGTH,
MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH,
MAX_CUSTOM_OBSERVABLE_TYPES,
MAX_DESCRIPTION_LENGTH,
MAX_LENGTH_PER_TAG,
MAX_OBSERVABLE_TYPE_KEY_LENGTH,
MAX_OBSERVABLE_TYPE_LABEL_LENGTH,
MAX_TAGS_PER_CASE,
MAX_TAGS_PER_TEMPLATE,
MAX_TEMPLATES_LENGTH,
Expand All @@ -38,6 +41,7 @@ import {
ToggleCustomFieldConfigurationRt,
NumberCustomFieldConfigurationRt,
TemplateConfigurationRt,
ObservableTypesConfigurationRt,
} from './v1';

describe('configure', () => {
Expand Down Expand Up @@ -96,6 +100,24 @@ describe('configure', () => {
});
});

it('has expected attributes in request with observableTypes', () => {
const request = {
...defaultRequest,
observableTypes: [
{
key: '371357ae-77ce-44bd-88b7-fbba9c80501f',
label: 'Example Label',
},
],
};
const query = ConfigurationRequestRt.decode(request);

expect(query).toStrictEqual({
_tag: 'Right',
right: request,
});
});

it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => {
const customFields = new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({
key: 'text_custom_field',
Expand Down Expand Up @@ -270,6 +292,24 @@ describe('configure', () => {
).toContain(`The length of the field templates is too long. Array must be of length <= 10.`);
});

it('has expected attributes in request with observableTypes', () => {
const request = {
...defaultRequest,
observableTypes: [
{
key: '371357ae-77ce-44bd-88b7-fbba9c80501f',
label: 'Example Label',
},
],
};
const query = ConfigurationPatchRequestRt.decode(request);

expect(query).toStrictEqual({
_tag: 'Right',
right: request,
});
});

it('removes foo:bar attributes from request', () => {
const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' });

Expand Down Expand Up @@ -926,4 +966,85 @@ describe('configure', () => {
});
});
});

describe('ObservableTypesConfigurationRt', () => {
it('should validate a correct observable types configuration', () => {
const validData = [
{ key: 'observable_key_1', label: 'Observable Label 1' },
{ key: 'observable_key_2', label: 'Observable Label 2' },
];

const result = ObservableTypesConfigurationRt.decode(validData);
expect(PathReporter.report(result).join()).toContain('No errors!');
});

it('should invalidate an observable types configuration with an invalid key', () => {
const invalidData = [{ key: 'Invalid Key!', label: 'Observable Label 1' }];

const result = ObservableTypesConfigurationRt.decode(invalidData);
expect(PathReporter.report(result).join()).not.toContain('No errors!');
});

it('should invalidate an observable types configuration with a missing label', () => {
const invalidData = [{ key: 'observable_key_1' }];

const result = ObservableTypesConfigurationRt.decode(invalidData);
expect(PathReporter.report(result).join()).not.toContain('No errors!');
});

it('should accept an observable types configuration with an empty array', () => {
const invalidData: unknown[] = [];

const result = ObservableTypesConfigurationRt.decode(invalidData);
expect(PathReporter.report(result).join()).toContain('No errors!');
});

it('should invalidate an observable types configuration with a label exceeding max length', () => {
const invalidData = [
{ key: 'observable_key_1', label: 'a'.repeat(MAX_OBSERVABLE_TYPE_LABEL_LENGTH + 1) },
];

const result = ObservableTypesConfigurationRt.decode(invalidData);
expect(PathReporter.report(result).join()).not.toContain('No errors!');
});

it('should invalidate an observable types configuration with a key exceeding max length', () => {
const invalidData = [{ key: 'a'.repeat(MAX_OBSERVABLE_TYPE_KEY_LENGTH + 1), label: 'label' }];

const result = ObservableTypesConfigurationRt.decode(invalidData);
expect(PathReporter.report(result).join()).not.toContain('No errors!');
});

it('should invalidate an observable types configuration with observableTypes count exceeding max', () => {
const invalidData = new Array(MAX_CUSTOM_OBSERVABLE_TYPES + 1).fill({
key: 'foo',
label: 'label',
});

const result = ObservableTypesConfigurationRt.decode(invalidData);
expect(PathReporter.report(result).join()).not.toContain('No errors!');
});

it('accepts a uuid as an key', () => {
const key = uuidv4();

const query = ObservableTypesConfigurationRt.decode([{ key, label: 'Observable Label 1' }]);

expect(query).toStrictEqual({
_tag: 'Right',
right: [{ key, label: 'Observable Label 1' }],
});
});

it('accepts a slug as an key', () => {
const key = 'abc_key-1';

const query = ObservableTypesConfigurationRt.decode([{ key, label: 'Observable Label 1' }]);

expect(query).toStrictEqual({
_tag: 'Right',
right: [{ key, label: 'Observable Label 1' }],
});
});
});
});
Loading

0 comments on commit 3083706

Please sign in to comment.