mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
# Backport This will backport the following commits from `main` to `8.x`: - [[EDR Workflows] Add Signer option to Mac trusted apps (#197821)](https://github.com/elastic/kibana/pull/197821) <!--- Backport version: 9.4.3 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Konrad Szwarc","email":"konrad.szwarc@elastic.co"},"sourceCommit":{"committedDate":"2024-11-18T13:20:54Z","message":"[EDR Workflows] Add Signer option to Mac trusted apps (#197821)\n\nThis PR adds a Signer condition for trusted apps on macOS. Previously,\r\nusers could only build conditions using hash, path, and signer options\r\non Windows. With these changes, macOS also supports the Signer option,\r\nleaving only Linux limited to Path and Hash options.\r\n\r\n\r\nhttps://github.com/user-attachments/assets/ea8fb734-7884-451d-8873-e3a29861876b","sha":"55134abbedaf64dba41455b8f8fb6f97f162a0d6","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["v9.0.0","Team:Defend Workflows","release_note:feature","ci:cloud-deploy","ci:cloud-redeploy","backport:version","v8.17.0"],"title":"[EDR Workflows] Add Signer option to Mac trusted apps","number":197821,"url":"https://github.com/elastic/kibana/pull/197821","mergeCommit":{"message":"[EDR Workflows] Add Signer option to Mac trusted apps (#197821)\n\nThis PR adds a Signer condition for trusted apps on macOS. Previously,\r\nusers could only build conditions using hash, path, and signer options\r\non Windows. With these changes, macOS also supports the Signer option,\r\nleaving only Linux limited to Path and Hash options.\r\n\r\n\r\nhttps://github.com/user-attachments/assets/ea8fb734-7884-451d-8873-e3a29861876b","sha":"55134abbedaf64dba41455b8f8fb6f97f162a0d6"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/197821","number":197821,"mergeCommit":{"message":"[EDR Workflows] Add Signer option to Mac trusted apps (#197821)\n\nThis PR adds a Signer condition for trusted apps on macOS. Previously,\r\nusers could only build conditions using hash, path, and signer options\r\non Windows. With these changes, macOS also supports the Signer option,\r\nleaving only Linux limited to Path and Hash options.\r\n\r\n\r\nhttps://github.com/user-attachments/assets/ea8fb734-7884-451d-8873-e3a29861876b","sha":"55134abbedaf64dba41455b8f8fb6f97f162a0d6"}},{"branch":"8.x","label":"v8.17.0","branchLabelMappingKey":"^v8.17.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Konrad Szwarc <konrad.szwarc@elastic.co>
This commit is contained in:
parent
a813573e48
commit
3cb4e1ee99
14 changed files with 586 additions and 105 deletions
|
@ -21,19 +21,22 @@ export enum ConditionEntryField {
|
|||
HASH = 'process.hash.*',
|
||||
PATH = 'process.executable.caseless',
|
||||
SIGNER = 'process.Ext.code_signature',
|
||||
SIGNER_MAC = 'process.code_signature',
|
||||
}
|
||||
|
||||
export enum EntryFieldType {
|
||||
HASH = '.hash.',
|
||||
EXECUTABLE = '.executable.caseless',
|
||||
PATH = '.path',
|
||||
SIGNER = '.Ext.code_signature',
|
||||
SIGNER = '.code_signature',
|
||||
}
|
||||
|
||||
export type TrustedAppConditionEntryField =
|
||||
| 'process.hash.*'
|
||||
| 'process.executable.caseless'
|
||||
| 'process.Ext.code_signature';
|
||||
| 'process.Ext.code_signature'
|
||||
| 'process.code_signature';
|
||||
|
||||
export type BlocklistConditionEntryField =
|
||||
| 'file.hash.*'
|
||||
| 'file.path'
|
||||
|
|
|
@ -9,13 +9,9 @@ import type {
|
|||
ExceptionListItemSchema,
|
||||
CreateExceptionListItemSchema,
|
||||
UpdateExceptionListItemSchema,
|
||||
EntriesArray,
|
||||
} from '@kbn/securitysolution-io-ts-list-types';
|
||||
import {
|
||||
ENDPOINT_EVENT_FILTERS_LIST_ID,
|
||||
ENDPOINT_TRUSTED_APPS_LIST_ID,
|
||||
ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID,
|
||||
ENDPOINT_BLOCKLISTS_LIST_ID,
|
||||
} from '@kbn/securitysolution-list-constants';
|
||||
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
|
||||
import { ConditionEntryField } from '@kbn/securitysolution-utils';
|
||||
import { BaseDataGenerator } from './base_data_generator';
|
||||
import { BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG } from '../service/artifacts/constants';
|
||||
|
@ -150,7 +146,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator<ExceptionList
|
|||
generateTrustedApp(overrides: Partial<ExceptionListItemSchema> = {}): ExceptionListItemSchema {
|
||||
return this.generate({
|
||||
name: `Trusted app (${this.randomString(5)})`,
|
||||
list_id: ENDPOINT_TRUSTED_APPS_LIST_ID,
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
@ -173,10 +169,33 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator<ExceptionList
|
|||
};
|
||||
}
|
||||
|
||||
generateTrustedAppSignerEntry(os = 'windows'): EntriesArray {
|
||||
return [
|
||||
{
|
||||
field: os === 'windows' ? 'process.Ext.code_signature' : 'process.code_signature',
|
||||
entries: [
|
||||
{
|
||||
field: 'trusted',
|
||||
value: 'true',
|
||||
type: 'match',
|
||||
operator: 'included',
|
||||
},
|
||||
{
|
||||
field: 'subject_name',
|
||||
value: 'foo',
|
||||
type: 'match',
|
||||
operator: 'included',
|
||||
},
|
||||
],
|
||||
type: 'nested',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
generateEventFilter(overrides: Partial<ExceptionListItemSchema> = {}): ExceptionListItemSchema {
|
||||
return this.generate({
|
||||
name: `Event filter (${this.randomString(5)})`,
|
||||
list_id: ENDPOINT_EVENT_FILTERS_LIST_ID,
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.eventFilters.id,
|
||||
entries: [
|
||||
{
|
||||
field: 'process.pe.company',
|
||||
|
@ -224,7 +243,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator<ExceptionList
|
|||
): ExceptionListItemSchema {
|
||||
return this.generate({
|
||||
name: `Host Isolation (${this.randomString(5)})`,
|
||||
list_id: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID,
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id,
|
||||
os_types: ['macos', 'linux', 'windows'],
|
||||
entries: [
|
||||
{
|
||||
|
@ -308,7 +327,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator<ExceptionList
|
|||
|
||||
return this.generate({
|
||||
name: `Blocklist ${this.randomString(5)}`,
|
||||
list_id: ENDPOINT_BLOCKLISTS_LIST_ID,
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.blocklists.id,
|
||||
item_id: `generator_endpoint_blocklist_${this.seededUUIDv4()}`,
|
||||
tags: [this.randomChoice([BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG])],
|
||||
os_types: [os],
|
||||
|
|
|
@ -14,8 +14,8 @@ import {
|
|||
import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils';
|
||||
import type {
|
||||
TrustedAppConditionEntry,
|
||||
NewTrustedApp,
|
||||
PutTrustedAppsRequestParams,
|
||||
NewTrustedApp,
|
||||
} from '../types';
|
||||
|
||||
describe('When invoking Trusted Apps Schema', () => {
|
||||
|
@ -105,14 +105,15 @@ describe('When invoking Trusted Apps Schema', () => {
|
|||
value: 'c:/programs files/Anti-Virus',
|
||||
...(data || {}),
|
||||
});
|
||||
const createNewTrustedApp = <T>(data?: T): NewTrustedApp => ({
|
||||
name: 'Some Anti-Virus App',
|
||||
description: 'this one is ok',
|
||||
os: OperatingSystem.WINDOWS,
|
||||
effectScope: { type: 'global' },
|
||||
entries: [createConditionEntry()],
|
||||
...(data || {}),
|
||||
});
|
||||
const createNewTrustedApp = <T>(data?: T): NewTrustedApp =>
|
||||
({
|
||||
name: 'Some Anti-Virus App',
|
||||
description: 'this one is ok',
|
||||
os: OperatingSystem.WINDOWS,
|
||||
effectScope: { type: 'global' },
|
||||
entries: [createConditionEntry()],
|
||||
...(data || {}),
|
||||
} as NewTrustedApp);
|
||||
const body = PostTrustedAppCreateRequestSchema.body;
|
||||
|
||||
it('should not error on a valid message', () => {
|
||||
|
@ -389,14 +390,15 @@ describe('When invoking Trusted Apps Schema', () => {
|
|||
value: 'c:/programs files/Anti-Virus',
|
||||
...(data || {}),
|
||||
});
|
||||
const createNewTrustedApp = <T>(data?: T): NewTrustedApp => ({
|
||||
name: 'Some Anti-Virus App',
|
||||
description: 'this one is ok',
|
||||
os: OperatingSystem.WINDOWS,
|
||||
effectScope: { type: 'global' },
|
||||
entries: [createConditionEntry()],
|
||||
...(data || {}),
|
||||
});
|
||||
const createNewTrustedApp = <T>(data?: T): NewTrustedApp =>
|
||||
({
|
||||
name: 'Some Anti-Virus App',
|
||||
description: 'this one is ok',
|
||||
os: OperatingSystem.WINDOWS,
|
||||
effectScope: { type: 'global' },
|
||||
entries: [createConditionEntry()],
|
||||
...(data || {}),
|
||||
} as NewTrustedApp);
|
||||
|
||||
const updateParams = <T>(data?: T): PutTrustedAppsRequestParams => ({
|
||||
id: 'validId',
|
||||
|
|
|
@ -80,6 +80,15 @@ const LinuxEntrySchema = schema.object({
|
|||
|
||||
const MacEntrySchema = schema.object({
|
||||
...CommonEntrySchema,
|
||||
field: schema.oneOf([
|
||||
schema.literal(ConditionEntryField.HASH),
|
||||
schema.literal(ConditionEntryField.PATH),
|
||||
schema.literal(ConditionEntryField.SIGNER_MAC),
|
||||
]),
|
||||
value: schema.string({
|
||||
validate: (field: string) =>
|
||||
field.length > 0 ? undefined : `invalidField.${ConditionEntryField.SIGNER_MAC}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const entriesSchemaOptions = {
|
||||
|
|
|
@ -36,23 +36,32 @@ export interface TrustedAppConditionEntry<T extends ConditionEntryField = Condit
|
|||
value: string;
|
||||
}
|
||||
|
||||
export type MacosLinuxConditionEntry = TrustedAppConditionEntry<
|
||||
export type LinuxConditionEntry = TrustedAppConditionEntry<
|
||||
ConditionEntryField.HASH | ConditionEntryField.PATH
|
||||
>;
|
||||
export type WindowsConditionEntry = TrustedAppConditionEntry<
|
||||
ConditionEntryField.HASH | ConditionEntryField.PATH | ConditionEntryField.SIGNER
|
||||
>;
|
||||
|
||||
export interface MacosLinuxConditionEntries {
|
||||
os: OperatingSystem.LINUX | OperatingSystem.MAC;
|
||||
entries: MacosLinuxConditionEntry[];
|
||||
export type MacosConditionEntry = TrustedAppConditionEntry<
|
||||
ConditionEntryField.HASH | ConditionEntryField.PATH | ConditionEntryField.SIGNER_MAC
|
||||
>;
|
||||
|
||||
interface LinuxConditionEntries {
|
||||
os: OperatingSystem.LINUX;
|
||||
entries: LinuxConditionEntry[];
|
||||
}
|
||||
|
||||
export interface WindowsConditionEntries {
|
||||
interface WindowsConditionEntries {
|
||||
os: OperatingSystem.WINDOWS;
|
||||
entries: WindowsConditionEntry[];
|
||||
}
|
||||
|
||||
interface MacosConditionEntries {
|
||||
os: OperatingSystem.MAC;
|
||||
entries: MacosConditionEntry[];
|
||||
}
|
||||
|
||||
export interface GlobalEffectScope {
|
||||
type: 'global';
|
||||
}
|
||||
|
@ -70,7 +79,7 @@ export type NewTrustedApp = {
|
|||
name: string;
|
||||
description?: string;
|
||||
effectScope: EffectScope;
|
||||
} & (MacosLinuxConditionEntries | WindowsConditionEntries);
|
||||
} & (LinuxConditionEntries | WindowsConditionEntries | MacosConditionEntries);
|
||||
|
||||
/** A trusted app entry */
|
||||
export type TrustedApp = NewTrustedApp & {
|
||||
|
|
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
* 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 { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
|
||||
import {
|
||||
createArtifactList,
|
||||
createPerPolicyArtifact,
|
||||
removeExceptionsList,
|
||||
trustedAppsFormSelectors,
|
||||
} from '../../tasks/artifacts';
|
||||
import type { IndexedFleetEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy';
|
||||
import { createAgentPolicyTask, getEndpointIntegrationVersion } from '../../tasks/fleet';
|
||||
import { login } from '../../tasks/login';
|
||||
|
||||
const {
|
||||
openTrustedApps,
|
||||
selectOs,
|
||||
openFieldSelector,
|
||||
expectedFieldOptions,
|
||||
selectField,
|
||||
fillOutValueField,
|
||||
fillOutTrustedAppsFlyout,
|
||||
submitForm,
|
||||
validateSuccessPopup,
|
||||
validateRenderedCondition,
|
||||
clickAndConditionButton,
|
||||
validateRenderedConditions,
|
||||
deleteTrustedAppItem,
|
||||
removeSingleCondition,
|
||||
expectAllFieldOptionsRendered,
|
||||
expectFieldOptionsNotRendered,
|
||||
} = trustedAppsFormSelectors;
|
||||
|
||||
describe(
|
||||
'Trusted Apps',
|
||||
{
|
||||
tags: ['@ess', '@serverless', '@skipInServerlessMKI'], // @skipInServerlessMKI until kibana is rebuilt after merge
|
||||
},
|
||||
() => {
|
||||
let indexedPolicy: IndexedFleetEndpointPolicyResponse;
|
||||
|
||||
before(() => {
|
||||
getEndpointIntegrationVersion().then((version) => {
|
||||
createAgentPolicyTask(version).then((data) => {
|
||||
indexedPolicy = data;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
login();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (indexedPolicy) {
|
||||
cy.task('deleteIndexedFleetEndpointPolicies', indexedPolicy);
|
||||
}
|
||||
});
|
||||
|
||||
const createArtifactBodyRequest = (multiCondition = false) => ({
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id,
|
||||
entries: [
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
field: 'trusted',
|
||||
operator: 'included',
|
||||
type: 'match',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
field: 'subject_name',
|
||||
value: 'TestSignature',
|
||||
type: 'match',
|
||||
operator: 'included',
|
||||
},
|
||||
],
|
||||
field: 'process.code_signature',
|
||||
type: 'nested',
|
||||
},
|
||||
...(multiCondition
|
||||
? [
|
||||
{
|
||||
field: 'process.hash.sha1',
|
||||
value: '323769d194406183912bb903e7fe738221543348',
|
||||
type: 'match',
|
||||
operator: 'included',
|
||||
},
|
||||
{
|
||||
field: 'process.executable.caseless',
|
||||
value: '/dev/null',
|
||||
type: 'match',
|
||||
operator: 'included',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
os_types: ['macos'],
|
||||
});
|
||||
|
||||
describe('Renders Trusted Apps form fields', () => {
|
||||
it('Correctly renders all blocklist fields for different OSs', () => {
|
||||
openTrustedApps({ create: true });
|
||||
selectOs('windows');
|
||||
expectFieldOptionsNotRendered();
|
||||
openFieldSelector();
|
||||
expectAllFieldOptionsRendered();
|
||||
|
||||
selectOs('macos');
|
||||
expectFieldOptionsNotRendered();
|
||||
openFieldSelector();
|
||||
expectAllFieldOptionsRendered();
|
||||
|
||||
selectOs('linux');
|
||||
expectFieldOptionsNotRendered();
|
||||
openFieldSelector();
|
||||
expectedFieldOptions(['Path', 'Hash']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Handles CRUD with signature field', () => {
|
||||
afterEach(() => {
|
||||
removeExceptionsList(ENDPOINT_ARTIFACT_LISTS.trustedApps.id);
|
||||
});
|
||||
|
||||
it('Correctly creates a trusted app with a single signature field on Mac', () => {
|
||||
const expectedCondition = /AND\s*process\.code_signature\s*IS\s*TestSignature/;
|
||||
|
||||
openTrustedApps({ create: true });
|
||||
fillOutTrustedAppsFlyout();
|
||||
selectOs('macos');
|
||||
openFieldSelector();
|
||||
selectField();
|
||||
fillOutValueField('TestSignature');
|
||||
submitForm();
|
||||
validateSuccessPopup('create');
|
||||
validateRenderedCondition(expectedCondition);
|
||||
});
|
||||
|
||||
describe('Correctly updates and deletes Mac os trusted app with single signature field', () => {
|
||||
let itemId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
createArtifactList(ENDPOINT_ARTIFACT_LISTS.trustedApps.id);
|
||||
createPerPolicyArtifact('Test TrustedApp', createArtifactBodyRequest()).then(
|
||||
(response) => {
|
||||
itemId = response.body.item_id;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('Updates Mac os single signature field trusted app item', () => {
|
||||
const expectedCondition = /AND\s*process\.code_signature\s*IS\s*TestSignatureNext/;
|
||||
openTrustedApps({ itemId });
|
||||
fillOutValueField('Next');
|
||||
submitForm();
|
||||
validateSuccessPopup('update');
|
||||
validateRenderedCondition(expectedCondition);
|
||||
});
|
||||
|
||||
it('Deletes a blocklist item', () => {
|
||||
openTrustedApps();
|
||||
deleteTrustedAppItem();
|
||||
validateSuccessPopup('delete');
|
||||
});
|
||||
});
|
||||
|
||||
it('Correctly creates a trusted app with a multiple conditions on Mac', () => {
|
||||
const expectedCondition =
|
||||
/\s*OSIS\s*Mac\s*AND\s*process\.code_signature\s*IS\s*TestSignature\s*AND\s*process\.hash\.\*\s*IS\s*323769d194406183912bb903e7fe738221543348\s*AND\s*process\.executable\.caselessIS\s*\/dev\/null\s*/;
|
||||
|
||||
openTrustedApps({ create: true });
|
||||
fillOutTrustedAppsFlyout();
|
||||
selectOs('macos');
|
||||
// Set signature field
|
||||
openFieldSelector();
|
||||
selectField();
|
||||
fillOutValueField('TestSignature');
|
||||
// Add another condition
|
||||
clickAndConditionButton();
|
||||
// Set hash field
|
||||
openFieldSelector(1, 1);
|
||||
selectField('Hash', 1, 1);
|
||||
fillOutValueField('323769d194406183912bb903e7fe738221543348', 1, 1);
|
||||
// Add another condition
|
||||
clickAndConditionButton();
|
||||
// Set path field
|
||||
openFieldSelector(1, 2);
|
||||
selectField('Path', 1, 2);
|
||||
fillOutValueField('/dev/null', 1, 2);
|
||||
|
||||
submitForm();
|
||||
validateSuccessPopup('create');
|
||||
validateRenderedConditions(expectedCondition);
|
||||
});
|
||||
|
||||
describe('Correctly updates and deletes Mac os trusted app with multiple conditions', () => {
|
||||
let itemId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
createArtifactList(ENDPOINT_ARTIFACT_LISTS.trustedApps.id);
|
||||
createPerPolicyArtifact('Test TrustedApp', createArtifactBodyRequest(true)).then(
|
||||
(response) => {
|
||||
itemId = response.body.item_id;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('Updates Mac os multiple condition trusted app item', () => {
|
||||
const expectedCondition =
|
||||
/\s*AND\s*process\.code_signature\s*IS\s*TestSignature\s*AND\s*process\.executable\.caselessIS\s*\/dev\/null\s*/;
|
||||
openTrustedApps({ itemId });
|
||||
removeSingleCondition(1, 1);
|
||||
submitForm();
|
||||
validateSuccessPopup('update');
|
||||
validateRenderedCondition(expectedCondition);
|
||||
});
|
||||
|
||||
it('Deletes a blocklist item', () => {
|
||||
openTrustedApps();
|
||||
deleteTrustedAppItem();
|
||||
validateSuccessPopup('delete');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
|
@ -18,7 +18,7 @@ import {
|
|||
EXCEPTION_LIST_ITEM_URL,
|
||||
EXCEPTION_LIST_URL,
|
||||
} from '@kbn/securitysolution-list-constants';
|
||||
import { APP_BLOCKLIST_PATH } from '../../../../common/constants';
|
||||
import { APP_BLOCKLIST_PATH, APP_TRUSTED_APPS_PATH } from '../../../../common/constants';
|
||||
import { loadPage, request } from './common';
|
||||
|
||||
export const removeAllArtifacts = () => {
|
||||
|
@ -108,6 +108,128 @@ export const yieldFirstPolicyID = (): Cypress.Chainable<string> =>
|
|||
return body.items[0].id;
|
||||
});
|
||||
|
||||
export const trustedAppsFormSelectors = {
|
||||
selectOs: (os: 'windows' | 'macos' | 'linux') => {
|
||||
cy.getByTestSubj('trustedApps-form-osSelectField').click();
|
||||
cy.get(`button[role="option"][id="${os}"]`).click();
|
||||
},
|
||||
|
||||
openFieldSelector: (group = 1, entry = 0) => {
|
||||
cy.getByTestSubj(
|
||||
`trustedApps-form-conditionsBuilder-group${group}-entry${entry}-field`
|
||||
).click();
|
||||
},
|
||||
|
||||
selectField: (field: 'Signature' | 'Hash' | 'Path' = 'Signature', group = 1, entry = 0) => {
|
||||
cy.getByTestSubj(
|
||||
`trustedApps-form-conditionsBuilder-group${group}-entry${entry}-field-type-${field}`
|
||||
).click();
|
||||
},
|
||||
|
||||
fillOutValueField: (value: string, group = 1, entry = 0) => {
|
||||
cy.getByTestSubj(`trustedApps-form-conditionsBuilder-group${group}-entry${entry}-value`).type(
|
||||
value
|
||||
);
|
||||
},
|
||||
|
||||
clickAndConditionButton: () => {
|
||||
cy.getByTestSubj('trustedApps-form-conditionsBuilder-group1-AndButton').click();
|
||||
},
|
||||
|
||||
submitForm: () => {
|
||||
cy.getByTestSubj('trustedAppsListPage-flyout-submitButton').click();
|
||||
},
|
||||
|
||||
fillOutTrustedAppsFlyout: () => {
|
||||
cy.getByTestSubj('trustedApps-form-nameTextField').type('Test TrustedApp');
|
||||
cy.getByTestSubj('trustedApps-form-descriptionField').type('Test Description');
|
||||
},
|
||||
|
||||
expectedFieldOptions: (fields = ['Path', 'Hash', 'Signature']) => {
|
||||
if (fields.length) {
|
||||
fields.forEach((field) => {
|
||||
cy.getByTestSubj(
|
||||
`trustedApps-form-conditionsBuilder-group1-entry0-field-type-${field}`
|
||||
).contains(field);
|
||||
});
|
||||
} else {
|
||||
const fields2 = ['Path', 'Hash', 'Signature'];
|
||||
fields2.forEach((field) => {
|
||||
cy.getByTestSubj(
|
||||
`trustedApps-form-conditionsBuilder-group1-entry0-field-type-${field}`
|
||||
).should('not.exist');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
expectAllFieldOptionsRendered: () => {
|
||||
trustedAppsFormSelectors.expectedFieldOptions();
|
||||
},
|
||||
|
||||
expectFieldOptionsNotRendered: () => {
|
||||
trustedAppsFormSelectors.expectedFieldOptions([]);
|
||||
},
|
||||
|
||||
openTrustedApps: ({ create, itemId }: { create?: boolean; itemId?: string } = {}) => {
|
||||
if (!create && !itemId) {
|
||||
loadPage(APP_TRUSTED_APPS_PATH);
|
||||
} else if (create) {
|
||||
loadPage(`${APP_TRUSTED_APPS_PATH}?show=create`);
|
||||
} else if (itemId) {
|
||||
loadPage(`${APP_TRUSTED_APPS_PATH}?itemId=${itemId}&show=edit`);
|
||||
}
|
||||
},
|
||||
|
||||
validateSuccessPopup: (type: 'create' | 'update' | 'delete') => {
|
||||
let expectedTitle = '';
|
||||
switch (type) {
|
||||
case 'create':
|
||||
expectedTitle = '"Test TrustedApp" has been added to your trusted applications.';
|
||||
break;
|
||||
case 'update':
|
||||
expectedTitle = '"Test TrustedApp" has been updated';
|
||||
break;
|
||||
case 'delete':
|
||||
expectedTitle = '"Test TrustedApp" has been removed from trusted applications.';
|
||||
break;
|
||||
}
|
||||
cy.getByTestSubj('euiToastHeader__title').contains(expectedTitle);
|
||||
},
|
||||
|
||||
validateRenderedCondition: (expectedCondition: RegExp) => {
|
||||
cy.getByTestSubj('trustedAppsListPage-card')
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.getByTestSubj('trustedAppsListPage-card-criteriaConditions-os')
|
||||
.invoke('text')
|
||||
.should('match', /OS\s*IS\s*Mac/);
|
||||
cy.getByTestSubj('trustedAppsListPage-card-criteriaConditions-condition')
|
||||
.invoke('text')
|
||||
.should('match', expectedCondition);
|
||||
});
|
||||
},
|
||||
validateRenderedConditions: (expectedConditions: RegExp) => {
|
||||
cy.getByTestSubj('trustedAppsListPage-card-criteriaConditions')
|
||||
.invoke('text')
|
||||
.should('match', expectedConditions);
|
||||
},
|
||||
removeSingleCondition: (group = 1, entry = 0) => {
|
||||
cy.getByTestSubj(
|
||||
`trustedApps-form-conditionsBuilder-group${group}-entry${entry}-remove`
|
||||
).click();
|
||||
},
|
||||
deleteTrustedAppItem: () => {
|
||||
cy.getByTestSubj('trustedAppsListPage-card')
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.getByTestSubj('trustedAppsListPage-card-header-actions-button').click();
|
||||
});
|
||||
|
||||
cy.getByTestSubj('trustedAppsListPage-card-cardDeleteAction').click();
|
||||
cy.getByTestSubj('trustedAppsListPage-deleteModal-submitButton').click();
|
||||
},
|
||||
};
|
||||
|
||||
export const blocklistFormSelectors = {
|
||||
expectSingleOperator: (field: 'Path' | 'Signature' | 'Hash') => {
|
||||
cy.getByTestSubj('blocklist-form-field-select').contains(field);
|
||||
|
|
|
@ -8,18 +8,14 @@
|
|||
import { ConditionEntryField } from '@kbn/securitysolution-utils';
|
||||
import type {
|
||||
TrustedAppConditionEntry,
|
||||
MacosLinuxConditionEntry,
|
||||
WindowsConditionEntry,
|
||||
LinuxConditionEntry,
|
||||
} from '../../../../../common/endpoint/types';
|
||||
|
||||
export const isWindowsTrustedAppCondition = (
|
||||
export const isSignerFieldExcluded = (
|
||||
condition: TrustedAppConditionEntry
|
||||
): condition is WindowsConditionEntry => {
|
||||
return condition.field === ConditionEntryField.SIGNER || true;
|
||||
};
|
||||
|
||||
export const isMacosLinuxTrustedAppCondition = (
|
||||
condition: TrustedAppConditionEntry
|
||||
): condition is MacosLinuxConditionEntry => {
|
||||
return condition.field !== ConditionEntryField.SIGNER;
|
||||
): condition is LinuxConditionEntry => {
|
||||
return (
|
||||
condition.field !== ConditionEntryField.SIGNER &&
|
||||
condition.field !== ConditionEntryField.SIGNER_MAC
|
||||
);
|
||||
};
|
||||
|
|
|
@ -152,13 +152,13 @@ describe('Condition entry input', () => {
|
|||
expect(superSelectProps.options.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should be able to select two options when MAC OS', () => {
|
||||
it('should be able to select three options when MAC OS', () => {
|
||||
const element = mount(getElement('testCheckSignatureOption', { os: OperatingSystem.MAC }));
|
||||
const superSelectProps = element
|
||||
.find('[data-test-subj="testCheckSignatureOption-field"]')
|
||||
.first()
|
||||
.props() as EuiSuperSelectProps<string>;
|
||||
expect(superSelectProps.options.length).toBe(2);
|
||||
expect(superSelectProps.options.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should have operator value selected when field is HASH', () => {
|
||||
|
|
|
@ -143,6 +143,18 @@ export const ConditionEntryInput = memo<ConditionEntryInputProps>(
|
|||
},
|
||||
]
|
||||
: []),
|
||||
...(os === OperatingSystem.MAC
|
||||
? [
|
||||
{
|
||||
dropdownDisplay: getDropdownDisplay(ConditionEntryField.SIGNER_MAC),
|
||||
inputDisplay: CONDITION_FIELD_TITLE[ConditionEntryField.SIGNER_MAC],
|
||||
value: ConditionEntryField.SIGNER_MAC,
|
||||
'data-test-subj': getTestId(
|
||||
`field-type-${CONDITION_FIELD_TITLE[ConditionEntryField.SIGNER_MAC]}`
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}, [getTestId, os]);
|
||||
|
||||
|
@ -224,7 +236,7 @@ export const ConditionEntryInput = memo<ConditionEntryInputProps>(
|
|||
</ConditionEntryCell>
|
||||
</InputItem>
|
||||
<InputItem gridArea="remove">
|
||||
{/* Unicode `nbsp` is used below so that Remove button is property displayed */}
|
||||
{/* Unicode `nbsp` is used below so that Remove button is properly displayed */}
|
||||
<ConditionEntryCell showLabel={showLabels} label={'\u00A0'}>
|
||||
<EuiButtonIcon
|
||||
color="danger"
|
||||
|
|
|
@ -44,10 +44,7 @@ import {
|
|||
getPolicyIdsFromArtifact,
|
||||
getArtifactTagsByPolicySelection,
|
||||
} from '../../../../../../common/endpoint/service/artifacts';
|
||||
import {
|
||||
isMacosLinuxTrustedAppCondition,
|
||||
isWindowsTrustedAppCondition,
|
||||
} from '../../state/type_guards';
|
||||
import { isSignerFieldExcluded } from '../../state/type_guards';
|
||||
|
||||
import {
|
||||
CONDITIONS_HEADER,
|
||||
|
@ -364,16 +361,38 @@ export const TrustedAppsForm = memo<ArtifactFormComponentProps>(
|
|||
entries: [] as ArtifactFormComponentProps['item']['entries'],
|
||||
};
|
||||
|
||||
if (os !== OperatingSystem.WINDOWS) {
|
||||
const macOsLinuxConditionEntry = item.entries.filter((entry) =>
|
||||
isMacosLinuxTrustedAppCondition(entry as TrustedAppConditionEntry)
|
||||
);
|
||||
nextItem.entries.push(...macOsLinuxConditionEntry);
|
||||
if (item.entries.length === 0) {
|
||||
nextItem.entries.push(defaultConditionEntry());
|
||||
}
|
||||
} else {
|
||||
nextItem.entries.push(...item.entries);
|
||||
switch (os) {
|
||||
case OperatingSystem.LINUX:
|
||||
nextItem.entries = item.entries.filter((entry) =>
|
||||
isSignerFieldExcluded(entry as TrustedAppConditionEntry)
|
||||
);
|
||||
if (item.entries.length === 0) {
|
||||
nextItem.entries.push(defaultConditionEntry());
|
||||
}
|
||||
break;
|
||||
case OperatingSystem.MAC:
|
||||
nextItem.entries = item.entries.map((entry) =>
|
||||
entry.field === ConditionEntryField.SIGNER
|
||||
? { ...entry, field: ConditionEntryField.SIGNER_MAC }
|
||||
: entry
|
||||
);
|
||||
if (item.entries.length === 0) {
|
||||
nextItem.entries.push(defaultConditionEntry());
|
||||
}
|
||||
break;
|
||||
case OperatingSystem.WINDOWS:
|
||||
nextItem.entries = item.entries.map((entry) =>
|
||||
entry.field === ConditionEntryField.SIGNER_MAC
|
||||
? { ...entry, field: ConditionEntryField.SIGNER }
|
||||
: entry
|
||||
);
|
||||
if (item.entries.length === 0) {
|
||||
nextItem.entries.push(defaultConditionEntry());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
nextItem.entries.push(...item.entries);
|
||||
break;
|
||||
}
|
||||
|
||||
processChanged(nextItem);
|
||||
|
@ -429,17 +448,15 @@ export const TrustedAppsForm = memo<ArtifactFormComponentProps>(
|
|||
entries: [],
|
||||
};
|
||||
const os = ((item.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS;
|
||||
if (os === OperatingSystem.WINDOWS) {
|
||||
nextItem.entries = [...item.entries, defaultConditionEntry()].filter((entry) =>
|
||||
isWindowsTrustedAppCondition(entry as TrustedAppConditionEntry)
|
||||
);
|
||||
} else {
|
||||
if (os === OperatingSystem.LINUX) {
|
||||
nextItem.entries = [
|
||||
...item.entries.filter((entry) =>
|
||||
isMacosLinuxTrustedAppCondition(entry as TrustedAppConditionEntry)
|
||||
isSignerFieldExcluded(entry as TrustedAppConditionEntry)
|
||||
),
|
||||
defaultConditionEntry(),
|
||||
];
|
||||
} else {
|
||||
nextItem.entries = [...item.entries, defaultConditionEntry()];
|
||||
}
|
||||
processChanged(nextItem);
|
||||
setHasFormChanged(true);
|
||||
|
|
|
@ -8,9 +8,10 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { ConditionEntryField } from '@kbn/securitysolution-utils';
|
||||
import type {
|
||||
MacosLinuxConditionEntry,
|
||||
LinuxConditionEntry,
|
||||
WindowsConditionEntry,
|
||||
OperatorFieldIds,
|
||||
MacosConditionEntry,
|
||||
} from '../../../../../common/endpoint/types';
|
||||
|
||||
export const NAME_LABEL = i18n.translate('xpack.securitySolution.trustedApps.name.label', {
|
||||
|
@ -68,6 +69,10 @@ export const CONDITION_FIELD_TITLE: { [K in ConditionEntryField]: string } = {
|
|||
'xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.signature',
|
||||
{ defaultMessage: 'Signature' }
|
||||
),
|
||||
[ConditionEntryField.SIGNER_MAC]: i18n.translate(
|
||||
'xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.signatureMac',
|
||||
{ defaultMessage: 'Signature' }
|
||||
),
|
||||
};
|
||||
|
||||
export const CONDITION_FIELD_DESCRIPTION: { [K in ConditionEntryField]: string } = {
|
||||
|
@ -83,6 +88,10 @@ export const CONDITION_FIELD_DESCRIPTION: { [K in ConditionEntryField]: string }
|
|||
'xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.description.signature',
|
||||
{ defaultMessage: 'The signer of the application' }
|
||||
),
|
||||
[ConditionEntryField.SIGNER_MAC]: i18n.translate(
|
||||
'xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.description.signatureMac',
|
||||
{ defaultMessage: 'The signer of the application' }
|
||||
),
|
||||
};
|
||||
|
||||
export const OPERATOR_TITLES: { [K in OperatorFieldIds]: string } = {
|
||||
|
@ -95,7 +104,10 @@ export const OPERATOR_TITLES: { [K in OperatorFieldIds]: string } = {
|
|||
};
|
||||
|
||||
export const ENTRY_PROPERTY_TITLES: Readonly<{
|
||||
[K in keyof Omit<MacosLinuxConditionEntry | WindowsConditionEntry, 'type'>]: string;
|
||||
[K in keyof Omit<
|
||||
LinuxConditionEntry | WindowsConditionEntry | MacosConditionEntry,
|
||||
'type'
|
||||
>]: string;
|
||||
}> = {
|
||||
field: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.entry.field', {
|
||||
defaultMessage: 'Field',
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants';
|
||||
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
|
||||
import type { TypeOf } from '@kbn/config-schema';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
|
||||
|
@ -30,7 +30,8 @@ const ProcessHashField = schema.oneOf([
|
|||
schema.literal('process.hash.sha256'),
|
||||
]);
|
||||
const ProcessExecutablePath = schema.literal('process.executable.caseless');
|
||||
const ProcessCodeSigner = schema.literal('process.Ext.code_signature');
|
||||
const ProcessWindowsCodeSigner = schema.literal('process.Ext.code_signature');
|
||||
const ProcessMacCodeSigner = schema.literal('process.code_signature');
|
||||
|
||||
const ConditionEntryTypeSchema = schema.conditional(
|
||||
schema.siblingRef('field'),
|
||||
|
@ -43,7 +44,8 @@ const ConditionEntryOperatorSchema = schema.literal('included');
|
|||
type ConditionEntryFieldAllowedType =
|
||||
| TypeOf<typeof ProcessHashField>
|
||||
| TypeOf<typeof ProcessExecutablePath>
|
||||
| TypeOf<typeof ProcessCodeSigner>;
|
||||
| TypeOf<typeof ProcessWindowsCodeSigner>
|
||||
| TypeOf<typeof ProcessMacCodeSigner>;
|
||||
|
||||
type TrustedAppConditionEntry<
|
||||
T extends ConditionEntryFieldAllowedType = ConditionEntryFieldAllowedType
|
||||
|
@ -54,7 +56,8 @@ type TrustedAppConditionEntry<
|
|||
operator: 'included';
|
||||
value: string;
|
||||
}
|
||||
| TypeOf<typeof WindowsSignerEntrySchema>;
|
||||
| TypeOf<typeof SignerWindowsEntrySchema>
|
||||
| TypeOf<typeof SignerMacEntrySchema>;
|
||||
|
||||
/*
|
||||
* A generic Entry schema to be used for a specific entry schema depending on the OS
|
||||
|
@ -85,11 +88,10 @@ const CommonEntrySchema = {
|
|||
),
|
||||
};
|
||||
|
||||
// Windows Signer entries use a Nested field that checks to ensure
|
||||
// Windows/MacOS Signer entries use a Nested field that checks to ensure
|
||||
// that the certificate is trusted
|
||||
const WindowsSignerEntrySchema = schema.object({
|
||||
const SignerEntrySchema = {
|
||||
type: schema.literal('nested'),
|
||||
field: ProcessCodeSigner,
|
||||
entries: schema.arrayOf(
|
||||
schema.oneOf([
|
||||
schema.object({
|
||||
|
@ -107,10 +109,28 @@ const WindowsSignerEntrySchema = schema.object({
|
|||
]),
|
||||
{ minSize: 2, maxSize: 2 }
|
||||
),
|
||||
};
|
||||
|
||||
const SignerWindowsEntrySchema = schema.object({
|
||||
...SignerEntrySchema,
|
||||
field: ProcessWindowsCodeSigner,
|
||||
});
|
||||
|
||||
const SignerMacEntrySchema = schema.object({
|
||||
...SignerEntrySchema,
|
||||
field: ProcessMacCodeSigner,
|
||||
});
|
||||
|
||||
const WindowsEntrySchema = schema.oneOf([
|
||||
WindowsSignerEntrySchema,
|
||||
SignerWindowsEntrySchema,
|
||||
schema.object({
|
||||
...CommonEntrySchema,
|
||||
field: schema.oneOf([ProcessHashField, ProcessExecutablePath]),
|
||||
}),
|
||||
]);
|
||||
|
||||
const MacEntrySchema = schema.oneOf([
|
||||
SignerMacEntrySchema,
|
||||
schema.object({
|
||||
...CommonEntrySchema,
|
||||
field: schema.oneOf([ProcessHashField, ProcessExecutablePath]),
|
||||
|
@ -121,10 +141,6 @@ const LinuxEntrySchema = schema.object({
|
|||
...CommonEntrySchema,
|
||||
});
|
||||
|
||||
const MacEntrySchema = schema.object({
|
||||
...CommonEntrySchema,
|
||||
});
|
||||
|
||||
const entriesSchemaOptions = {
|
||||
minSize: 1,
|
||||
validate(entries: TrustedAppConditionEntry[]) {
|
||||
|
@ -172,7 +188,7 @@ const TrustedAppDataSchema = schema.object(
|
|||
|
||||
export class TrustedAppValidator extends BaseValidator {
|
||||
static isTrustedApp(item: { listId: string }): boolean {
|
||||
return item.listId === ENDPOINT_TRUSTED_APPS_LIST_ID;
|
||||
return item.listId === ENDPOINT_ARTIFACT_LISTS.trustedApps.id;
|
||||
}
|
||||
|
||||
protected async validateHasWritePrivilege(): Promise<void> {
|
||||
|
|
|
@ -206,26 +206,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
const body = trustedAppApiCall.getBody();
|
||||
|
||||
body.os_types = ['linux'];
|
||||
body.entries = [
|
||||
{
|
||||
field: 'process.Ext.code_signature',
|
||||
entries: [
|
||||
{
|
||||
field: 'trusted',
|
||||
value: 'true',
|
||||
type: 'match',
|
||||
operator: 'included',
|
||||
},
|
||||
{
|
||||
field: 'subject_name',
|
||||
value: 'foo',
|
||||
type: 'match',
|
||||
operator: 'included',
|
||||
},
|
||||
],
|
||||
type: 'nested',
|
||||
},
|
||||
];
|
||||
body.entries = exceptionsGenerator.generateTrustedAppSignerEntry();
|
||||
|
||||
await endpointPolicyManagerSupertest[trustedAppApiCall.method](trustedAppApiCall.path)
|
||||
.set('kbn-xsrf', 'true')
|
||||
|
@ -235,6 +216,58 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
.expect(anErrorMessageWith(/^.*(?!process\.Ext\.code_signature)/));
|
||||
});
|
||||
|
||||
it(`should error on [${trustedAppApiCall.method} if Mac signer field is used for Windows entry`, async () => {
|
||||
const body = trustedAppApiCall.getBody();
|
||||
|
||||
body.os_types = ['windows'];
|
||||
body.entries = exceptionsGenerator.generateTrustedAppSignerEntry('mac');
|
||||
|
||||
await endpointPolicyManagerSupertest[trustedAppApiCall.method](trustedAppApiCall.path)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`should error on [${trustedAppApiCall.method} if Windows signer field is used for Mac entry`, async () => {
|
||||
const body = trustedAppApiCall.getBody();
|
||||
|
||||
body.os_types = ['macos'];
|
||||
body.entries = exceptionsGenerator.generateTrustedAppSignerEntry();
|
||||
|
||||
await endpointPolicyManagerSupertest[trustedAppApiCall.method](trustedAppApiCall.path)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should not error if signer is set for a windows os entry item', async () => {
|
||||
const body = trustedAppApiCalls[0].getBody();
|
||||
|
||||
body.os_types = ['windows'];
|
||||
body.entries = exceptionsGenerator.generateTrustedAppSignerEntry();
|
||||
|
||||
await endpointPolicyManagerSupertest[trustedAppApiCalls[0].method](
|
||||
trustedAppApiCalls[0].path
|
||||
)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(body)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should not error if signer is set for a mac os entry item', async () => {
|
||||
const body = trustedAppApiCalls[0].getBody();
|
||||
|
||||
body.os_types = ['macos'];
|
||||
body.entries = exceptionsGenerator.generateTrustedAppSignerEntry('mac');
|
||||
|
||||
await endpointPolicyManagerSupertest[trustedAppApiCalls[0].method](
|
||||
trustedAppApiCalls[0].path
|
||||
)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(body)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it(`should error on [${trustedAppApiCall.method}] if more than one OS is set`, async () => {
|
||||
const body = trustedAppApiCall.getBody();
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue