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.8`: - [[Defend Workflows][E2E]Endpoint e2e response console (#155605)](https://github.com/elastic/kibana/pull/155605) <!--- Backport version: 8.9.7 --> ### 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":"2023-05-02T09:02:06Z","message":"[Defend Workflows][E2E]Endpoint e2e response console (#155605)\n\nDepends on https://github.com/elastic/kibana/pull/155519\r\n\r\nE2E coverage of `isolate`, `processes`, `kill-process` and\r\n`suspend-process` commands on mocked endpoint.\r\n\r\nE2E coverage of the above but on real endpoint is\r\n[here](https://github.com/elastic/kibana/pull/155519).\r\n\r\nBecause these tests are run against mocked data I've decided not to mock\r\n`kill-process` and `suspend-process` outcome (whether process is\r\nactually killed/suspended) because it would mean testing mocks\r\nthemselves. What is tested is the outcome the user sees ('Action\r\ncompleted').\r\n\r\n---------\r\n\r\nCo-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>","sha":"fd5309f6a02bce641c4baf79500acfe797e294f7","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Defend Workflows","v8.8.0","v8.9.0"],"number":155605,"url":"https://github.com/elastic/kibana/pull/155605","mergeCommit":{"message":"[Defend Workflows][E2E]Endpoint e2e response console (#155605)\n\nDepends on https://github.com/elastic/kibana/pull/155519\r\n\r\nE2E coverage of `isolate`, `processes`, `kill-process` and\r\n`suspend-process` commands on mocked endpoint.\r\n\r\nE2E coverage of the above but on real endpoint is\r\n[here](https://github.com/elastic/kibana/pull/155519).\r\n\r\nBecause these tests are run against mocked data I've decided not to mock\r\n`kill-process` and `suspend-process` outcome (whether process is\r\nactually killed/suspended) because it would mean testing mocks\r\nthemselves. What is tested is the outcome the user sees ('Action\r\ncompleted').\r\n\r\n---------\r\n\r\nCo-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>","sha":"fd5309f6a02bce641c4baf79500acfe797e294f7"}},"sourceBranch":"main","suggestedTargetBranches":["8.8"],"targetPullRequestStates":[{"branch":"8.8","label":"v8.8.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/155605","number":155605,"mergeCommit":{"message":"[Defend Workflows][E2E]Endpoint e2e response console (#155605)\n\nDepends on https://github.com/elastic/kibana/pull/155519\r\n\r\nE2E coverage of `isolate`, `processes`, `kill-process` and\r\n`suspend-process` commands on mocked endpoint.\r\n\r\nE2E coverage of the above but on real endpoint is\r\n[here](https://github.com/elastic/kibana/pull/155519).\r\n\r\nBecause these tests are run against mocked data I've decided not to mock\r\n`kill-process` and `suspend-process` outcome (whether process is\r\nactually killed/suspended) because it would mean testing mocks\r\nthemselves. What is tested is the outcome the user sees ('Action\r\ncompleted').\r\n\r\n---------\r\n\r\nCo-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>","sha":"fd5309f6a02bce641c4baf79500acfe797e294f7"}}]}] BACKPORT-->
This commit is contained in:
parent
41428d0808
commit
5ca00fc51f
8 changed files with 606 additions and 331 deletions
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { getEndpointListPath } from '../../../common/routing';
|
||||
import {
|
||||
checkEndpointListForOnlyIsolatedHosts,
|
||||
checkEndpointIsIsolated,
|
||||
checkFlyoutEndpointIsolation,
|
||||
filterOutIsolatedHosts,
|
||||
interceptActionRequests,
|
||||
|
@ -32,7 +32,8 @@ describe('Isolate command', () => {
|
|||
describe('from Manage', () => {
|
||||
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
|
||||
let isolatedEndpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
|
||||
|
||||
let isolatedEndpointHostnames: [string, string];
|
||||
let endpointHostnames: [string, string];
|
||||
before(() => {
|
||||
indexEndpointHosts({
|
||||
count: 2,
|
||||
|
@ -40,6 +41,10 @@ describe('Isolate command', () => {
|
|||
isolation: false,
|
||||
}).then((indexEndpoints) => {
|
||||
endpointData = indexEndpoints;
|
||||
endpointHostnames = [
|
||||
endpointData.data.hosts[0].host.name,
|
||||
endpointData.data.hosts[1].host.name,
|
||||
];
|
||||
});
|
||||
|
||||
indexEndpointHosts({
|
||||
|
@ -48,6 +53,10 @@ describe('Isolate command', () => {
|
|||
isolation: true,
|
||||
}).then((indexEndpoints) => {
|
||||
isolatedEndpointData = indexEndpoints;
|
||||
isolatedEndpointHostnames = [
|
||||
isolatedEndpointData.data.hosts[0].host.name,
|
||||
isolatedEndpointData.data.hosts[1].host.name,
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -67,13 +76,15 @@ describe('Isolate command', () => {
|
|||
beforeEach(() => {
|
||||
login();
|
||||
});
|
||||
// FLAKY: https://github.com/elastic/security-team/issues/6518
|
||||
it.skip('should allow filtering endpoint by Isolated status', () => {
|
||||
|
||||
it('should allow filtering endpoint by Isolated status', () => {
|
||||
cy.visit(APP_PATH + getEndpointListPath({ name: 'endpointList' }));
|
||||
closeAllToasts();
|
||||
filterOutIsolatedHosts();
|
||||
cy.contains('Showing 2 endpoints');
|
||||
checkEndpointListForOnlyIsolatedHosts();
|
||||
isolatedEndpointHostnames.forEach(checkEndpointIsIsolated);
|
||||
endpointHostnames.forEach((hostname) => {
|
||||
cy.contains(hostname).should('not.exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* 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 { ActionDetails } from '../../../../../common/endpoint/types';
|
||||
import type { ReturnTypeFromChainable } from '../../types';
|
||||
import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts';
|
||||
import {
|
||||
checkReturnedProcessesTable,
|
||||
inputConsoleCommand,
|
||||
openResponseConsoleFromEndpointList,
|
||||
performCommandInputChecks,
|
||||
submitCommand,
|
||||
waitForEndpointListPageToBeLoaded,
|
||||
} from '../../tasks/response_console';
|
||||
import {
|
||||
checkEndpointIsIsolated,
|
||||
checkEndpointIsNotIsolated,
|
||||
interceptActionRequests,
|
||||
sendActionResponse,
|
||||
} from '../../tasks/isolate';
|
||||
import { login } from '../../tasks/login';
|
||||
|
||||
describe('Response console', () => {
|
||||
beforeEach(() => {
|
||||
login();
|
||||
});
|
||||
|
||||
describe('Isolate command', () => {
|
||||
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
|
||||
let endpointHostname: string;
|
||||
let isolateRequestResponse: ActionDetails;
|
||||
|
||||
before(() => {
|
||||
indexEndpointHosts({ withResponseActions: false, isolation: false }).then(
|
||||
(indexEndpoints) => {
|
||||
endpointData = indexEndpoints;
|
||||
endpointHostname = endpointData.data.hosts[0].host.name;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (endpointData) {
|
||||
endpointData.cleanup();
|
||||
// @ts-expect-error ignore setting to undefined
|
||||
endpointData = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('should isolate host from response console', () => {
|
||||
waitForEndpointListPageToBeLoaded(endpointHostname);
|
||||
checkEndpointIsNotIsolated(endpointHostname);
|
||||
openResponseConsoleFromEndpointList();
|
||||
performCommandInputChecks('isolate');
|
||||
interceptActionRequests((responseBody) => {
|
||||
isolateRequestResponse = responseBody;
|
||||
}, 'isolate');
|
||||
|
||||
submitCommand();
|
||||
cy.contains('Action pending.').should('exist');
|
||||
cy.wait('@isolate').then(() => {
|
||||
sendActionResponse(isolateRequestResponse);
|
||||
});
|
||||
cy.contains('Action completed.', { timeout: 120000 }).should('exist');
|
||||
waitForEndpointListPageToBeLoaded(endpointHostname);
|
||||
checkEndpointIsIsolated(endpointHostname);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Release command', () => {
|
||||
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
|
||||
let endpointHostname: string;
|
||||
let releaseRequestResponse: ActionDetails;
|
||||
|
||||
before(() => {
|
||||
indexEndpointHosts({ withResponseActions: false, isolation: true }).then((indexEndpoints) => {
|
||||
endpointData = indexEndpoints;
|
||||
endpointHostname = endpointData.data.hosts[0].host.name;
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (endpointData) {
|
||||
endpointData.cleanup();
|
||||
// @ts-expect-error ignore setting to undefined
|
||||
endpointData = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('should release host from response console', () => {
|
||||
waitForEndpointListPageToBeLoaded(endpointHostname);
|
||||
checkEndpointIsIsolated(endpointHostname);
|
||||
openResponseConsoleFromEndpointList();
|
||||
performCommandInputChecks('release');
|
||||
interceptActionRequests((responseBody) => {
|
||||
releaseRequestResponse = responseBody;
|
||||
}, 'release');
|
||||
submitCommand();
|
||||
cy.contains('Action pending.').should('exist');
|
||||
cy.wait('@release').then(() => {
|
||||
sendActionResponse(releaseRequestResponse);
|
||||
});
|
||||
cy.contains('Action completed.', { timeout: 120000 }).should('exist');
|
||||
waitForEndpointListPageToBeLoaded(endpointHostname);
|
||||
checkEndpointIsNotIsolated(endpointHostname);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Processes command', () => {
|
||||
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
|
||||
let endpointHostname: string;
|
||||
let processesRequestResponse: ActionDetails;
|
||||
|
||||
before(() => {
|
||||
indexEndpointHosts({ withResponseActions: false, isolation: false }).then(
|
||||
(indexEndpoints) => {
|
||||
endpointData = indexEndpoints;
|
||||
endpointHostname = endpointData.data.hosts[0].host.name;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (endpointData) {
|
||||
endpointData.cleanup();
|
||||
// @ts-expect-error ignore setting to undefined
|
||||
endpointData = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('should return processes from response console', () => {
|
||||
waitForEndpointListPageToBeLoaded(endpointHostname);
|
||||
openResponseConsoleFromEndpointList();
|
||||
performCommandInputChecks('processes');
|
||||
interceptActionRequests((responseBody) => {
|
||||
processesRequestResponse = responseBody;
|
||||
}, 'processes');
|
||||
submitCommand();
|
||||
cy.contains('Action pending.').should('exist');
|
||||
cy.wait('@processes').then(() => {
|
||||
sendActionResponse(processesRequestResponse);
|
||||
});
|
||||
cy.getByTestSubj('getProcessesSuccessCallout', { timeout: 120000 }).within(() => {
|
||||
checkReturnedProcessesTable();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kill process command', () => {
|
||||
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
|
||||
let endpointHostname: string;
|
||||
let killProcessRequestResponse: ActionDetails;
|
||||
|
||||
before(() => {
|
||||
indexEndpointHosts({ withResponseActions: false, isolation: false }).then(
|
||||
(indexEndpoints) => {
|
||||
endpointData = indexEndpoints;
|
||||
endpointHostname = endpointData.data.hosts[0].host.name;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (endpointData) {
|
||||
endpointData.cleanup();
|
||||
// @ts-expect-error ignore setting to undefined
|
||||
endpointData = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('should kill process from response console', () => {
|
||||
waitForEndpointListPageToBeLoaded(endpointHostname);
|
||||
openResponseConsoleFromEndpointList();
|
||||
inputConsoleCommand(`kill-process --pid 1`);
|
||||
|
||||
interceptActionRequests((responseBody) => {
|
||||
killProcessRequestResponse = responseBody;
|
||||
}, 'kill-process');
|
||||
submitCommand();
|
||||
cy.contains('Action pending.').should('exist');
|
||||
cy.wait('@kill-process').then(() => {
|
||||
sendActionResponse(killProcessRequestResponse);
|
||||
});
|
||||
cy.contains('Action completed.', { timeout: 120000 }).should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suspend process command', () => {
|
||||
let endpointData: ReturnTypeFromChainable<typeof indexEndpointHosts>;
|
||||
let endpointHostname: string;
|
||||
let suspendProcessRequestResponse: ActionDetails;
|
||||
|
||||
before(() => {
|
||||
indexEndpointHosts({ withResponseActions: false, isolation: false }).then(
|
||||
(indexEndpoints) => {
|
||||
endpointData = indexEndpoints;
|
||||
endpointHostname = endpointData.data.hosts[0].host.name;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (endpointData) {
|
||||
endpointData.cleanup();
|
||||
// @ts-expect-error ignore setting to undefined
|
||||
endpointData = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('should suspend process from response console', () => {
|
||||
waitForEndpointListPageToBeLoaded(endpointHostname);
|
||||
openResponseConsoleFromEndpointList();
|
||||
inputConsoleCommand(`suspend-process --pid 1`);
|
||||
|
||||
interceptActionRequests((responseBody) => {
|
||||
suspendProcessRequestResponse = responseBody;
|
||||
}, 'suspend-process');
|
||||
submitCommand();
|
||||
cy.contains('Action pending.').should('exist');
|
||||
cy.wait('@suspend-process').then(() => {
|
||||
sendActionResponse(suspendProcessRequestResponse);
|
||||
});
|
||||
cy.contains('Action completed.', { timeout: 120000 }).should('exist');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -8,7 +8,10 @@
|
|||
// / <reference types="cypress" />
|
||||
|
||||
import type { CasePostRequest } from '@kbn/cases-plugin/common/api';
|
||||
import { sendEndpointActionResponse } from '../../../../scripts/endpoint/agent_emulator/services/endpoint_response_actions';
|
||||
import {
|
||||
sendEndpointActionResponse,
|
||||
sendFleetActionResponse,
|
||||
} from '../../../../scripts/endpoint/common/response_actions';
|
||||
import type { DeleteAllEndpointDataResponse } from '../../../../scripts/endpoint/common/delete_all_endpoint_data';
|
||||
import { deleteAllEndpointData } from '../../../../scripts/endpoint/common/delete_all_endpoint_data';
|
||||
import { waitForEndpointToStreamData } from '../../../../scripts/endpoint/common/endpoint_metadata_services';
|
||||
|
@ -21,11 +24,7 @@ import {
|
|||
deleteIndexedEndpointPolicyResponse,
|
||||
indexEndpointPolicyResponse,
|
||||
} from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response';
|
||||
import type {
|
||||
ActionDetails,
|
||||
HostPolicyResponse,
|
||||
LogsEndpointActionResponse,
|
||||
} from '../../../../common/endpoint/types';
|
||||
import type { ActionDetails, HostPolicyResponse } from '../../../../common/endpoint/types';
|
||||
import type { IndexEndpointHostsCyTaskOptions } from '../types';
|
||||
import type {
|
||||
IndexedEndpointRuleAlerts,
|
||||
|
@ -162,9 +161,17 @@ export const dataLoaders = (
|
|||
sendHostActionResponse: async (data: {
|
||||
action: ActionDetails;
|
||||
state: { state?: 'success' | 'failure' };
|
||||
}): Promise<LogsEndpointActionResponse> => {
|
||||
}): Promise<null> => {
|
||||
const { esClient } = await stackServicesPromise;
|
||||
return sendEndpointActionResponse(esClient, data.action, { state: data.state.state });
|
||||
const fleetResponse = await sendFleetActionResponse(esClient, data.action, {
|
||||
state: data.state.state,
|
||||
});
|
||||
|
||||
if (!fleetResponse.error) {
|
||||
await sendEndpointActionResponse(esClient, data.action, { state: data.state.state });
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
deleteAllEndpointData: async ({
|
||||
|
|
|
@ -143,3 +143,18 @@ export const checkEndpointListForOnlyUnIsolatedHosts = (): void =>
|
|||
checkEndpointListForIsolatedHosts(false);
|
||||
export const checkEndpointListForOnlyIsolatedHosts = (): void =>
|
||||
checkEndpointListForIsolatedHosts(true);
|
||||
|
||||
export const checkEndpointIsolationStatus = (
|
||||
endpointHostname: string,
|
||||
expectIsolated: boolean
|
||||
): void => {
|
||||
const chainer = expectIsolated ? 'contain.text' : 'not.contain.text';
|
||||
|
||||
cy.contains(endpointHostname).parents('td').siblings('td').eq(0).should(chainer, 'Isolated');
|
||||
};
|
||||
|
||||
export const checkEndpointIsIsolated = (endpointHostname: string): void =>
|
||||
checkEndpointIsolationStatus(endpointHostname, true);
|
||||
|
||||
export const checkEndpointIsNotIsolated = (endpointHostname: string): void =>
|
||||
checkEndpointIsolationStatus(endpointHostname, false);
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { closeAllToasts } from './close_all_toasts';
|
||||
import { APP_ENDPOINTS_PATH } from '../../../../common/constants';
|
||||
import Chainable = Cypress.Chainable;
|
||||
|
||||
export const waitForEndpointListPageToBeLoaded = (endpointHostname: string): void => {
|
||||
cy.visit(APP_ENDPOINTS_PATH);
|
||||
|
@ -56,3 +57,16 @@ export const performCommandInputChecks = (command: string) => {
|
|||
selectCommandFromHelpMenu(command);
|
||||
checkInputForCommandPresence(command);
|
||||
};
|
||||
|
||||
export const checkReturnedProcessesTable = (): Chainable<JQuery<HTMLTableRowElement>> => {
|
||||
['USER', 'PID', 'ENTITY ID', 'COMMAND'].forEach((header) => {
|
||||
cy.contains(header);
|
||||
});
|
||||
|
||||
return cy
|
||||
.get('tbody')
|
||||
.find('tr')
|
||||
.then((rows) => {
|
||||
expect(rows.length).to.be.greaterThan(0);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -9,12 +9,9 @@ import { set } from '@kbn/safer-lodash-set';
|
|||
import type { Client } from '@elastic/elasticsearch';
|
||||
import type { ToolingLog } from '@kbn/tooling-log';
|
||||
import type { KbnClient } from '@kbn/test';
|
||||
import { sendEndpointActionResponse, sendFleetActionResponse } from '../../common/response_actions';
|
||||
import { BaseRunningService } from '../../common/base_running_service';
|
||||
import {
|
||||
fetchEndpointActionList,
|
||||
sendEndpointActionResponse,
|
||||
sendFleetActionResponse,
|
||||
} from './endpoint_response_actions';
|
||||
import { fetchEndpointActionList } from './endpoint_response_actions';
|
||||
import type { ActionDetails } from '../../../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
|
|
|
@ -6,43 +6,9 @@
|
|||
*/
|
||||
|
||||
import type { KbnClient } from '@kbn/test';
|
||||
import type { Client } from '@elastic/elasticsearch';
|
||||
import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
|
||||
import * as cborx from 'cbor-x';
|
||||
import { basename } from 'path';
|
||||
import { generateFileMetadataDocumentMock } from '../../../../server/endpoint/services/actions/mocks';
|
||||
import { getFileDownloadId } from '../../../../common/endpoint/service/response_actions/get_file_download_id';
|
||||
import { checkInFleetAgent } from '../../common/fleet_services';
|
||||
import { sendEndpointMetadataUpdate } from '../../common/endpoint_metadata_services';
|
||||
import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator';
|
||||
import {
|
||||
ENDPOINT_ACTION_RESPONSES_INDEX,
|
||||
BASE_ENDPOINT_ACTION_ROUTE,
|
||||
FILE_STORAGE_DATA_INDEX,
|
||||
FILE_STORAGE_METADATA_INDEX,
|
||||
} from '../../../../common/endpoint/constants';
|
||||
import type {
|
||||
ActionDetails,
|
||||
ActionListApiResponse,
|
||||
EndpointActionData,
|
||||
EndpointActionResponse,
|
||||
LogsEndpointActionResponse,
|
||||
GetProcessesActionOutputContent,
|
||||
ResponseActionGetFileOutputContent,
|
||||
ResponseActionGetFileParameters,
|
||||
FileUploadMetadata,
|
||||
ResponseActionExecuteOutputContent,
|
||||
} from '../../../../common/endpoint/types';
|
||||
import { BASE_ENDPOINT_ACTION_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import type { ActionListApiResponse } from '../../../../common/endpoint/types';
|
||||
import type { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions';
|
||||
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
|
||||
|
||||
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
|
||||
|
||||
export const fleetActionGenerator = new FleetActionGenerator();
|
||||
|
||||
export const endpointActionGenerator = new EndpointActionGenerator();
|
||||
|
||||
export const sleep = (ms: number = 1000) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export const fetchEndpointActionList = async (
|
||||
kbn: KbnClient,
|
||||
|
@ -76,279 +42,3 @@ export const fetchEndpointActionList = async (
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendFleetActionResponse = async (
|
||||
esClient: Client,
|
||||
action: ActionDetails,
|
||||
{ state }: { state?: 'success' | 'failure' } = {}
|
||||
): Promise<EndpointActionResponse> => {
|
||||
const fleetResponse = fleetActionGenerator.generateResponse({
|
||||
action_id: action.id,
|
||||
agent_id: action.agents[0],
|
||||
action_response: {
|
||||
endpoint: {
|
||||
ack: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 20% of the time we generate an error
|
||||
if (state === 'failure' || (!state && fleetActionGenerator.randomFloat() < 0.2)) {
|
||||
fleetResponse.action_response = {};
|
||||
fleetResponse.error = 'Agent failed to deliver message to endpoint due to unknown error';
|
||||
} else {
|
||||
// show it as success (generator currently always generates a `error`, so delete it)
|
||||
delete fleetResponse.error;
|
||||
}
|
||||
|
||||
await esClient.index(
|
||||
{
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
body: fleetResponse,
|
||||
refresh: 'wait_for',
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
|
||||
return fleetResponse;
|
||||
};
|
||||
|
||||
export const sendEndpointActionResponse = async (
|
||||
esClient: Client,
|
||||
action: ActionDetails,
|
||||
{ state }: { state?: 'success' | 'failure' } = {}
|
||||
): Promise<LogsEndpointActionResponse> => {
|
||||
const endpointResponse = endpointActionGenerator.generateResponse({
|
||||
agent: { id: action.agents[0] },
|
||||
EndpointActions: {
|
||||
action_id: action.id,
|
||||
data: {
|
||||
command: action.command as EndpointActionData['command'],
|
||||
comment: '',
|
||||
...getOutputDataIfNeeded(action),
|
||||
},
|
||||
started_at: action.startedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// 20% of the time we generate an error
|
||||
if (state === 'failure' || (state !== 'success' && endpointActionGenerator.randomFloat() < 0.2)) {
|
||||
endpointResponse.error = {
|
||||
message: 'Endpoint encountered an error and was unable to apply action to host',
|
||||
};
|
||||
|
||||
if (
|
||||
endpointResponse.EndpointActions.data.command === 'get-file' &&
|
||||
endpointResponse.EndpointActions.data.output
|
||||
) {
|
||||
(
|
||||
endpointResponse.EndpointActions.data.output.content as ResponseActionGetFileOutputContent
|
||||
).code = endpointActionGenerator.randomGetFileFailureCode();
|
||||
}
|
||||
|
||||
if (
|
||||
endpointResponse.EndpointActions.data.command === 'execute' &&
|
||||
endpointResponse.EndpointActions.data.output
|
||||
) {
|
||||
(
|
||||
endpointResponse.EndpointActions.data.output.content as ResponseActionExecuteOutputContent
|
||||
).stderr = 'execute command timed out';
|
||||
}
|
||||
}
|
||||
|
||||
await esClient.index({
|
||||
index: ENDPOINT_ACTION_RESPONSES_INDEX,
|
||||
body: endpointResponse,
|
||||
refresh: 'wait_for',
|
||||
});
|
||||
|
||||
// ------------------------------------------
|
||||
// Post Action Response tasks
|
||||
// ------------------------------------------
|
||||
|
||||
// For isolate, If the response is not an error, then also send a metadata update
|
||||
if (action.command === 'isolate' && !endpointResponse.error) {
|
||||
for (const agentId of action.agents) {
|
||||
await Promise.all([
|
||||
sendEndpointMetadataUpdate(esClient, agentId, {
|
||||
Endpoint: {
|
||||
state: {
|
||||
isolation: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
checkInFleetAgent(esClient, agentId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// For UnIsolate, if response is not an Error, then also send metadata update
|
||||
if (action.command === 'unisolate' && !endpointResponse.error) {
|
||||
for (const agentId of action.agents) {
|
||||
await Promise.all([
|
||||
sendEndpointMetadataUpdate(esClient, agentId, {
|
||||
Endpoint: {
|
||||
state: {
|
||||
isolation: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
checkInFleetAgent(esClient, agentId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// For `get-file`, upload a file to ES
|
||||
if ((action.command === 'execute' || action.command === 'get-file') && !endpointResponse.error) {
|
||||
const filePath =
|
||||
action.command === 'execute'
|
||||
? '/execute/file/path'
|
||||
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
(
|
||||
action as ActionDetails<
|
||||
ResponseActionGetFileOutputContent,
|
||||
ResponseActionGetFileParameters
|
||||
>
|
||||
)?.parameters?.path!;
|
||||
|
||||
const fileName = basename(filePath.replace(/\\/g, '/'));
|
||||
const fileMetaDoc: FileUploadMetadata = generateFileMetadataDocumentMock({
|
||||
action_id: action.id,
|
||||
agent_id: action.agents[0],
|
||||
contents: [
|
||||
{
|
||||
sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28',
|
||||
file_name: fileName ?? 'bad_file.txt',
|
||||
path: filePath,
|
||||
size: 4,
|
||||
type: 'file',
|
||||
},
|
||||
],
|
||||
file: {
|
||||
attributes: ['archive', 'compressed'],
|
||||
ChunkSize: 4194304,
|
||||
compression: 'deflate',
|
||||
hash: {
|
||||
sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28',
|
||||
},
|
||||
mime_type: 'application/zip',
|
||||
name: action.command === 'execute' ? 'full-output.zip' : 'upload.zip',
|
||||
extension: 'zip',
|
||||
size: 125,
|
||||
Status: 'READY',
|
||||
type: 'file',
|
||||
},
|
||||
src: 'endpoint',
|
||||
});
|
||||
|
||||
// Index the file's metadata
|
||||
const fileMeta = await esClient.index({
|
||||
index: FILE_STORAGE_METADATA_INDEX,
|
||||
id: getFileDownloadId(action, action.agents[0]),
|
||||
body: fileMetaDoc,
|
||||
refresh: 'wait_for',
|
||||
});
|
||||
|
||||
// Index the file content (just one chunk)
|
||||
// call to `.index()` copied from File plugin here:
|
||||
// https://github.com/elastic/kibana/blob/main/src/plugins/files/server/blob_storage_service/adapters/es/content_stream/content_stream.ts#L195
|
||||
await esClient
|
||||
.index(
|
||||
{
|
||||
index: FILE_STORAGE_DATA_INDEX,
|
||||
id: `${fileMeta._id}.0`,
|
||||
document: cborx.encode({
|
||||
bid: fileMeta._id,
|
||||
last: true,
|
||||
data: Buffer.from(
|
||||
'UEsDBAoACQAAAFZeRFWpAsDLHwAAABMAAAAMABwAYmFkX2ZpbGUudHh0VVQJAANTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAMOcoyEq/Q4VyG02U9O0LRbGlwP/y5SOCfRKqLz1rsBQSwcIqQLAyx8AAAATAAAAUEsBAh4DCgAJAAAAVl5EVakCwMsfAAAAEwAAAAwAGAAAAAAAAQAAAKSBAAAAAGJhZF9maWxlLnR4dFVUBQADU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFIAAAB1AAAAAAA=',
|
||||
'base64'
|
||||
),
|
||||
}),
|
||||
refresh: 'wait_for',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/cbor',
|
||||
accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
.then(() => sleep(2000));
|
||||
}
|
||||
|
||||
return endpointResponse;
|
||||
};
|
||||
|
||||
type ResponseOutput<TOutputContent extends object = object> = Pick<
|
||||
LogsEndpointActionResponse<TOutputContent>['EndpointActions']['data'],
|
||||
'output'
|
||||
>;
|
||||
|
||||
const getOutputDataIfNeeded = (action: ActionDetails): ResponseOutput => {
|
||||
const commentUppercase = (action?.comment ?? '').toUpperCase();
|
||||
|
||||
switch (action.command) {
|
||||
case 'running-processes':
|
||||
return {
|
||||
output: {
|
||||
type: 'json',
|
||||
content: {
|
||||
entries: endpointActionGenerator.randomResponseActionProcesses(100),
|
||||
},
|
||||
},
|
||||
} as ResponseOutput<GetProcessesActionOutputContent>;
|
||||
|
||||
case 'get-file':
|
||||
return {
|
||||
output: {
|
||||
type: 'json',
|
||||
content: {
|
||||
code: 'ra_get-file_success_done',
|
||||
zip_size: 123,
|
||||
contents: [
|
||||
{
|
||||
type: 'file',
|
||||
path: (
|
||||
action as ActionDetails<
|
||||
ResponseActionGetFileOutputContent,
|
||||
ResponseActionGetFileParameters
|
||||
>
|
||||
).parameters?.path,
|
||||
size: 1234,
|
||||
file_name: 'bad_file.txt',
|
||||
sha256: '9558c5cb39622e9b3653203e772b129d6c634e7dbd7af1b244352fc1d704601f',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as ResponseOutput<ResponseActionGetFileOutputContent>;
|
||||
|
||||
case 'execute':
|
||||
const executeOutput: Partial<ResponseActionExecuteOutputContent> = {
|
||||
output_file_id: getFileDownloadId(action, action.agents[0]),
|
||||
};
|
||||
|
||||
// Error?
|
||||
if (commentUppercase.indexOf('EXECUTE:FAILURE') > -1) {
|
||||
executeOutput.stdout = '';
|
||||
executeOutput.stdout_truncated = false;
|
||||
executeOutput.output_file_stdout_truncated = false;
|
||||
} else {
|
||||
executeOutput.stderr = '';
|
||||
executeOutput.stderr_truncated = false;
|
||||
executeOutput.output_file_stderr_truncated = false;
|
||||
}
|
||||
|
||||
return {
|
||||
output: endpointActionGenerator.generateExecuteActionResponseOutput({
|
||||
content: executeOutput,
|
||||
}),
|
||||
} as ResponseOutput<ResponseActionExecuteOutputContent>;
|
||||
|
||||
default:
|
||||
return { output: undefined };
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,311 @@
|
|||
/*
|
||||
* 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 { Client } from '@elastic/elasticsearch';
|
||||
import { basename } from 'path';
|
||||
import * as cborx from 'cbor-x';
|
||||
import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
|
||||
import { FleetActionGenerator } from '../../../common/endpoint/data_generators/fleet_action_generator';
|
||||
import { EndpointActionGenerator } from '../../../common/endpoint/data_generators/endpoint_action_generator';
|
||||
import type {
|
||||
ActionDetails,
|
||||
EndpointActionData,
|
||||
EndpointActionResponse,
|
||||
FileUploadMetadata,
|
||||
GetProcessesActionOutputContent,
|
||||
LogsEndpointActionResponse,
|
||||
ResponseActionExecuteOutputContent,
|
||||
ResponseActionGetFileOutputContent,
|
||||
ResponseActionGetFileParameters,
|
||||
} from '../../../common/endpoint/types';
|
||||
import { getFileDownloadId } from '../../../common/endpoint/service/response_actions/get_file_download_id';
|
||||
import {
|
||||
ENDPOINT_ACTION_RESPONSES_INDEX,
|
||||
FILE_STORAGE_DATA_INDEX,
|
||||
FILE_STORAGE_METADATA_INDEX,
|
||||
} from '../../../common/endpoint/constants';
|
||||
import { sendEndpointMetadataUpdate } from './endpoint_metadata_services';
|
||||
import { checkInFleetAgent } from './fleet_services';
|
||||
import { generateFileMetadataDocumentMock } from '../../../server/endpoint/services/actions/mocks';
|
||||
|
||||
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
|
||||
export const fleetActionGenerator = new FleetActionGenerator();
|
||||
export const endpointActionGenerator = new EndpointActionGenerator();
|
||||
export const sleep = (ms: number = 1000) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export const sendFleetActionResponse = async (
|
||||
esClient: Client,
|
||||
action: ActionDetails,
|
||||
{ state }: { state?: 'success' | 'failure' } = {}
|
||||
): Promise<EndpointActionResponse> => {
|
||||
const fleetResponse = fleetActionGenerator.generateResponse({
|
||||
action_id: action.id,
|
||||
agent_id: action.agents[0],
|
||||
action_response: {
|
||||
endpoint: {
|
||||
ack: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 20% of the time we generate an error
|
||||
if (state === 'failure' || (!state && fleetActionGenerator.randomFloat() < 0.2)) {
|
||||
fleetResponse.action_response = {};
|
||||
fleetResponse.error = 'Agent failed to deliver message to endpoint due to unknown error';
|
||||
} else {
|
||||
// show it as success (generator currently always generates a `error`, so delete it)
|
||||
delete fleetResponse.error;
|
||||
}
|
||||
|
||||
await esClient.index(
|
||||
{
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
body: fleetResponse,
|
||||
refresh: 'wait_for',
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
|
||||
return fleetResponse;
|
||||
};
|
||||
export const sendEndpointActionResponse = async (
|
||||
esClient: Client,
|
||||
action: ActionDetails,
|
||||
{ state }: { state?: 'success' | 'failure' } = {}
|
||||
): Promise<LogsEndpointActionResponse> => {
|
||||
const endpointResponse = endpointActionGenerator.generateResponse({
|
||||
agent: { id: action.agents[0] },
|
||||
EndpointActions: {
|
||||
action_id: action.id,
|
||||
data: {
|
||||
command: action.command as EndpointActionData['command'],
|
||||
comment: '',
|
||||
...getOutputDataIfNeeded(action),
|
||||
},
|
||||
started_at: action.startedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// 20% of the time we generate an error
|
||||
if (state === 'failure' || (state !== 'success' && endpointActionGenerator.randomFloat() < 0.2)) {
|
||||
endpointResponse.error = {
|
||||
message: 'Endpoint encountered an error and was unable to apply action to host',
|
||||
};
|
||||
|
||||
if (
|
||||
endpointResponse.EndpointActions.data.command === 'get-file' &&
|
||||
endpointResponse.EndpointActions.data.output
|
||||
) {
|
||||
(
|
||||
endpointResponse.EndpointActions.data.output.content as ResponseActionGetFileOutputContent
|
||||
).code = endpointActionGenerator.randomGetFileFailureCode();
|
||||
}
|
||||
|
||||
if (
|
||||
endpointResponse.EndpointActions.data.command === 'execute' &&
|
||||
endpointResponse.EndpointActions.data.output
|
||||
) {
|
||||
(
|
||||
endpointResponse.EndpointActions.data.output.content as ResponseActionExecuteOutputContent
|
||||
).stderr = 'execute command timed out';
|
||||
}
|
||||
}
|
||||
|
||||
await esClient.index({
|
||||
index: ENDPOINT_ACTION_RESPONSES_INDEX,
|
||||
body: endpointResponse,
|
||||
refresh: 'wait_for',
|
||||
});
|
||||
|
||||
// ------------------------------------------
|
||||
// Post Action Response tasks
|
||||
// ------------------------------------------
|
||||
|
||||
// For isolate, If the response is not an error, then also send a metadata update
|
||||
if (action.command === 'isolate' && !endpointResponse.error) {
|
||||
for (const agentId of action.agents) {
|
||||
await Promise.all([
|
||||
sendEndpointMetadataUpdate(esClient, agentId, {
|
||||
Endpoint: {
|
||||
state: {
|
||||
isolation: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
checkInFleetAgent(esClient, agentId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// For UnIsolate, if response is not an Error, then also send metadata update
|
||||
if (action.command === 'unisolate' && !endpointResponse.error) {
|
||||
for (const agentId of action.agents) {
|
||||
await Promise.all([
|
||||
sendEndpointMetadataUpdate(esClient, agentId, {
|
||||
Endpoint: {
|
||||
state: {
|
||||
isolation: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
checkInFleetAgent(esClient, agentId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// For `get-file`, upload a file to ES
|
||||
if ((action.command === 'execute' || action.command === 'get-file') && !endpointResponse.error) {
|
||||
const filePath =
|
||||
action.command === 'execute'
|
||||
? '/execute/file/path'
|
||||
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
(
|
||||
action as ActionDetails<
|
||||
ResponseActionGetFileOutputContent,
|
||||
ResponseActionGetFileParameters
|
||||
>
|
||||
)?.parameters?.path!;
|
||||
|
||||
const fileName = basename(filePath.replace(/\\/g, '/'));
|
||||
const fileMetaDoc: FileUploadMetadata = generateFileMetadataDocumentMock({
|
||||
action_id: action.id,
|
||||
agent_id: action.agents[0],
|
||||
contents: [
|
||||
{
|
||||
sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28',
|
||||
file_name: fileName ?? 'bad_file.txt',
|
||||
path: filePath,
|
||||
size: 4,
|
||||
type: 'file',
|
||||
},
|
||||
],
|
||||
file: {
|
||||
attributes: ['archive', 'compressed'],
|
||||
ChunkSize: 4194304,
|
||||
compression: 'deflate',
|
||||
hash: {
|
||||
sha256: '8d61673c9d782297b3c774ded4e3d88f31a8869a8f25cf5cdd402ba6822d1d28',
|
||||
},
|
||||
mime_type: 'application/zip',
|
||||
name: action.command === 'execute' ? 'full-output.zip' : 'upload.zip',
|
||||
extension: 'zip',
|
||||
size: 125,
|
||||
Status: 'READY',
|
||||
type: 'file',
|
||||
},
|
||||
src: 'endpoint',
|
||||
});
|
||||
|
||||
// Index the file's metadata
|
||||
const fileMeta = await esClient.index({
|
||||
index: FILE_STORAGE_METADATA_INDEX,
|
||||
id: getFileDownloadId(action, action.agents[0]),
|
||||
body: fileMetaDoc,
|
||||
refresh: 'wait_for',
|
||||
});
|
||||
|
||||
// Index the file content (just one chunk)
|
||||
// call to `.index()` copied from File plugin here:
|
||||
// https://github.com/elastic/kibana/blob/main/src/plugins/files/server/blob_storage_service/adapters/es/content_stream/content_stream.ts#L195
|
||||
await esClient
|
||||
.index(
|
||||
{
|
||||
index: FILE_STORAGE_DATA_INDEX,
|
||||
id: `${fileMeta._id}.0`,
|
||||
document: cborx.encode({
|
||||
bid: fileMeta._id,
|
||||
last: true,
|
||||
data: Buffer.from(
|
||||
'UEsDBAoACQAAAFZeRFWpAsDLHwAAABMAAAAMABwAYmFkX2ZpbGUudHh0VVQJAANTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAMOcoyEq/Q4VyG02U9O0LRbGlwP/y5SOCfRKqLz1rsBQSwcIqQLAyx8AAAATAAAAUEsBAh4DCgAJAAAAVl5EVakCwMsfAAAAEwAAAAwAGAAAAAAAAQAAAKSBAAAAAGJhZF9maWxlLnR4dFVUBQADU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFIAAAB1AAAAAAA=',
|
||||
'base64'
|
||||
),
|
||||
}),
|
||||
refresh: 'wait_for',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/cbor',
|
||||
accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
.then(() => sleep(2000));
|
||||
}
|
||||
|
||||
return endpointResponse;
|
||||
};
|
||||
type ResponseOutput<TOutputContent extends object = object> = Pick<
|
||||
LogsEndpointActionResponse<TOutputContent>['EndpointActions']['data'],
|
||||
'output'
|
||||
>;
|
||||
const getOutputDataIfNeeded = (action: ActionDetails): ResponseOutput => {
|
||||
const commentUppercase = (action?.comment ?? '').toUpperCase();
|
||||
|
||||
switch (action.command) {
|
||||
case 'running-processes':
|
||||
return {
|
||||
output: {
|
||||
type: 'json',
|
||||
content: {
|
||||
entries: endpointActionGenerator.randomResponseActionProcesses(100),
|
||||
},
|
||||
},
|
||||
} as ResponseOutput<GetProcessesActionOutputContent>;
|
||||
|
||||
case 'get-file':
|
||||
return {
|
||||
output: {
|
||||
type: 'json',
|
||||
content: {
|
||||
code: 'ra_get-file_success_done',
|
||||
zip_size: 123,
|
||||
contents: [
|
||||
{
|
||||
type: 'file',
|
||||
path: (
|
||||
action as ActionDetails<
|
||||
ResponseActionGetFileOutputContent,
|
||||
ResponseActionGetFileParameters
|
||||
>
|
||||
).parameters?.path,
|
||||
size: 1234,
|
||||
file_name: 'bad_file.txt',
|
||||
sha256: '9558c5cb39622e9b3653203e772b129d6c634e7dbd7af1b244352fc1d704601f',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as ResponseOutput<ResponseActionGetFileOutputContent>;
|
||||
|
||||
case 'execute':
|
||||
const executeOutput: Partial<ResponseActionExecuteOutputContent> = {
|
||||
output_file_id: getFileDownloadId(action, action.agents[0]),
|
||||
};
|
||||
|
||||
// Error?
|
||||
if (commentUppercase.indexOf('EXECUTE:FAILURE') > -1) {
|
||||
executeOutput.stdout = '';
|
||||
executeOutput.stdout_truncated = false;
|
||||
executeOutput.output_file_stdout_truncated = false;
|
||||
} else {
|
||||
executeOutput.stderr = '';
|
||||
executeOutput.stderr_truncated = false;
|
||||
executeOutput.output_file_stderr_truncated = false;
|
||||
}
|
||||
|
||||
return {
|
||||
output: endpointActionGenerator.generateExecuteActionResponseOutput({
|
||||
content: executeOutput,
|
||||
}),
|
||||
} as ResponseOutput<ResponseActionExecuteOutputContent>;
|
||||
|
||||
default:
|
||||
return { output: undefined };
|
||||
}
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue