[Security Solution] add doc signing tests (#156916)

This commit is contained in:
Joey F. Poon 2023-05-08 12:17:59 -05:00 committed by GitHub
parent a22561a524
commit 6591da49df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 200 additions and 1 deletions

View file

@ -167,4 +167,50 @@ describe('Response console', () => {
waitForCommandToBeExecuted();
});
});
describe('document signing', () => {
let response: IndexedFleetEndpointPolicyResponse;
let initialAgentData: Agent;
before(() => {
getAgentByHostName(endpointHostname).then((agentData) => {
initialAgentData = agentData;
});
getEndpointIntegrationVersion().then((version) =>
createAgentPolicyTask(version).then((data) => {
response = data;
})
);
});
after(() => {
if (initialAgentData?.policy_id) {
reassignAgentPolicy(initialAgentData.id, initialAgentData.policy_id);
}
if (response) {
cy.task('deleteIndexedFleetEndpointPolicies', response);
}
});
it('should fail if data tampered', () => {
waitForEndpointListPageToBeLoaded(endpointHostname);
checkEndpointListForOnlyUnIsolatedHosts();
openResponseConsoleFromEndpointList();
performCommandInputChecks('isolate');
// stop host so that we ensure tamper happens before endpoint processes the action
cy.task('stopEndpointHost');
// get action doc before we submit command so we know when the new action doc is indexed
cy.task('getLatestActionDoc').then((previousActionDoc) => {
submitCommand();
cy.task('tamperActionDoc', previousActionDoc);
});
cy.task('startEndpointHost');
const actionValidationErrorMsg =
'Fleet action response error: Failed to validate action signature; check Endpoint logs for details';
cy.contains(actionValidationErrorMsg, { timeout: 120000 }).should('exist');
});
});
});

View file

@ -46,8 +46,11 @@ import {
indexEndpointRuleAlerts,
} from '../../../../common/endpoint/data_loaders/index_endpoint_rule_alerts';
import {
startEndpointHost,
createAndEnrollEndpointHost,
destroyEndpointHost,
getEndpointHosts,
stopEndpointHost,
} from '../../../../scripts/endpoint/common/endpoint_host_services';
/**
@ -220,5 +223,17 @@ export const dataLoadersForRealEndpoints = (
const { kbnClient } = await stackServicesPromise;
return destroyEndpointHost(kbnClient, createdHost).then(() => null);
},
stopEndpointHost: async () => {
const hosts = await getEndpointHosts();
const hostName = hosts[0].name;
return stopEndpointHost(hostName);
},
startEndpointHost: async () => {
const hosts = await getEndpointHosts();
const hostName = hosts[0].name;
return startEndpointHost(hostName);
},
});
};

View file

@ -0,0 +1,67 @@
/*
* 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.
*/
// / <reference types="cypress" />
import { get } from 'lodash';
import {
getLatestActionDoc,
updateActionDoc,
waitForNewActionDoc,
} from '../../../../scripts/endpoint/common/response_actions';
import { createRuntimeServices } from '../../../../scripts/endpoint/common/stack_services';
export const responseActionTasks = (
on: Cypress.PluginEvents,
config: Cypress.PluginConfigOptions
): void => {
const stackServicesPromise = createRuntimeServices({
kibanaUrl: config.env.KIBANA_URL,
elasticsearchUrl: config.env.ELASTICSEARCH_URL,
username: config.env.ELASTICSEARCH_USERNAME,
password: config.env.ELASTICSEARCH_PASSWORD,
asSuperuser: true,
});
on('task', {
getLatestActionDoc: async () => {
const { esClient } = await stackServicesPromise;
// cypress doesn't like resolved undefined values
return getLatestActionDoc(esClient).then((doc) => doc || null);
},
// previousActionDoc is used to determine when a new action doc is received
tamperActionDoc: async (previousActionDoc) => {
const { esClient } = await stackServicesPromise;
const newActionDoc = await waitForNewActionDoc(esClient, previousActionDoc);
if (!newActionDoc) {
throw new Error('no action doc found');
}
const signed = get(newActionDoc, '_source.signed');
const signedDataBuffer = Buffer.from(signed.data, 'base64');
const signedDataJson = JSON.parse(signedDataBuffer.toString());
const tamperedAgentsList = [...signedDataJson.agents, 'anotheragent'];
const tamperedData = {
...signedDataJson,
agents: tamperedAgentsList,
};
const tamperedDataString = Buffer.from(JSON.stringify(tamperedData), 'utf8').toString(
'base64'
);
const tamperedDoc = {
signed: {
...signed,
data: tamperedDataString,
},
};
return updateActionDoc(esClient, newActionDoc._id, tamperedDoc);
},
});
};

View file

@ -8,6 +8,8 @@
import { defineCypressConfig } from '@kbn/cypress-config';
// eslint-disable-next-line @kbn/imports/no_boundary_crossing
import { dataLoaders, dataLoadersForRealEndpoints } from './cypress/support/data_loaders';
// eslint-disable-next-line @kbn/imports/no_boundary_crossing
import { responseActionTasks } from './cypress/support/response_actions';
// eslint-disable-next-line import/no-default-export
export default defineCypressConfig({
@ -43,6 +45,7 @@ export default defineCypressConfig({
dataLoaders(on, config);
// Data loaders specific to "real" Endpoint testing
dataLoadersForRealEndpoints(on, config);
responseActionTasks(on, config);
},
},
});

View file

@ -264,3 +264,18 @@ const enrollHostWithFleet = async ({
agentId: agent.id,
};
};
export async function getEndpointHosts(): Promise<
Array<{ name: string; state: string; ipv4: string; image: string }>
> {
const output = await execa('multipass', ['list', '--format', 'json']);
return JSON.parse(output.stdout).list;
}
export function stopEndpointHost(hostName: string) {
return execa('multipass', ['stop', hostName]);
}
export function startEndpointHost(hostName: string) {
return execa('multipass', ['start', hostName]);
}

View file

@ -6,13 +6,15 @@
*/
import type { Client } from '@elastic/elasticsearch';
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { basename } from 'path';
import * as cborx from 'cbor-x';
import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
import { AGENT_ACTIONS_INDEX, 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,
EndpointAction,
EndpointActionData,
EndpointActionResponse,
FileUploadMetadata,
@ -309,3 +311,54 @@ const getOutputDataIfNeeded = (action: ActionDetails): ResponseOutput => {
return { output: undefined };
}
};
export async function getLatestActionDoc(
esClient: Client
): Promise<SearchHit<EndpointAction> | undefined> {
return (
await esClient.search<EndpointAction>({
index: AGENT_ACTIONS_INDEX,
ignore_unavailable: true,
query: {
match: {
type: 'INPUT_ACTION',
},
},
sort: {
'@timestamp': {
order: 'desc',
},
},
size: 1,
})
).hits.hits.at(0);
}
export async function waitForNewActionDoc(
esClient: Client,
previousActionDoc?: SearchHit<EndpointAction>,
options: {
maxAttempts: number;
interval: number;
} = { maxAttempts: 3, interval: 10000 }
): Promise<SearchHit<EndpointAction> | undefined> {
const { maxAttempts, interval } = options;
let attempts = 1;
let latestDoc = await getLatestActionDoc(esClient);
while ((!latestDoc || latestDoc._id === previousActionDoc?._id) && attempts <= maxAttempts) {
await new Promise((res) => setTimeout(res, interval));
latestDoc = await getLatestActionDoc(esClient);
attempts++;
}
return latestDoc;
}
export function updateActionDoc<T = unknown>(esClient: Client, id: string, doc: T) {
return esClient.update({
index: AGENT_ACTIONS_INDEX,
id,
doc,
refresh: true,
});
}