diff --git a/.buildkite/scout_ci_config.yml b/.buildkite/scout_ci_config.yml index 120268cea1bac..bfc9666793947 100644 --- a/.buildkite/scout_ci_config.yml +++ b/.buildkite/scout_ci_config.yml @@ -26,6 +26,7 @@ plugins: - navigation - observability - observability_onboarding + - osquery - painless_lab - profiling - search_getting_started diff --git a/x-pack/platform/plugins/shared/osquery/moon.yml b/x-pack/platform/plugins/shared/osquery/moon.yml index d5722ed848e9a..582988e0e4bad 100644 --- a/x-pack/platform/plugins/shared/osquery/moon.yml +++ b/x-pack/platform/plugins/shared/osquery/moon.yml @@ -85,6 +85,7 @@ dependsOn: - '@kbn/unified-search-plugin' - '@kbn/core-notifications-browser' - '@kbn/core-chrome-browser' + - '@kbn/scout' tags: - plugin - prod @@ -98,6 +99,7 @@ fileGroups: - scripts/**/* - scripts/**/*.json - server/**/* + - test/scout/**/* - public/common/schemas/*/*.json - '!target/**/*' tasks: diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/fixtures/constants.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/fixtures/constants.ts new file mode 100644 index 0000000000000..6961bb0db77a6 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/fixtures/constants.ts @@ -0,0 +1,62 @@ +/* + * 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 { OSQUERY_API_VERSION } from '../../common/constants'; + +export { OSQUERY_API_VERSION }; + +export const COMMON_HEADERS = { + 'kbn-xsrf': 'some-xsrf-token', + 'x-elastic-internal-origin': 'kibana', + 'Content-Type': 'application/json;charset=UTF-8', + 'elastic-api-version': OSQUERY_API_VERSION, +} as const; + +export const API_PATHS = { + DETECTION_RULES: 'api/detection_engine/rules', + OSQUERY_SAVED_QUERIES: 'api/osquery/saved_queries', + OSQUERY_PACKS: 'api/osquery/packs', +} as const; + +const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +export const getMinimalRule = (overrides: Record = {}) => ({ + type: 'query', + index: ['auditbeat-*'], + language: 'kuery', + query: '_id:*', + name: `Test rule ${uniqueId()}`, + description: 'Test rule for Osquery response actions', + risk_score: 21, + severity: 'low', + interval: '5m', + from: 'now-360s', + to: 'now', + enabled: false, + ...overrides, +}); + +export const getMinimalPack = (overrides: Record = {}) => ({ + name: `test-pack-${uniqueId()}`, + description: 'Test pack for Osquery Scout tests', + enabled: true, + queries: { + testQuery: { + query: 'select * from uptime;', + interval: 3600, + }, + }, + shards: {}, + ...overrides, +}); + +export const getMinimalSavedQuery = (overrides: Record = {}) => ({ + id: `test-saved-query-${uniqueId()}`, + query: 'select 1;', + interval: '3600', + ...overrides, +}); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/fixtures/index.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/fixtures/index.ts new file mode 100644 index 0000000000000..821959e26613e --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/fixtures/index.ts @@ -0,0 +1,44 @@ +/* + * 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 { ApiServicesFixture, ScoutTestFixtures, ScoutWorkerFixtures } from '@kbn/scout'; +import { apiTest as baseApiTest } from '@kbn/scout'; +import { + getOsqueryApiService, + type OsqueryApiService, +} from '../../common/services/osquery_api_service'; + +export interface OsqueryApiServicesFixture extends ApiServicesFixture { + osquery: OsqueryApiService; +} + +export const apiTest = baseApiTest.extend< + ScoutTestFixtures, + { apiServices: OsqueryApiServicesFixture } +>({ + apiServices: [ + async ( + { + apiServices, + kbnClient, + log, + }: { + apiServices: ApiServicesFixture; + kbnClient: ScoutWorkerFixtures['kbnClient']; + log: ScoutWorkerFixtures['log']; + }, + use: (extendedApiServices: OsqueryApiServicesFixture) => Promise + ) => { + const extendedApiServices = apiServices as OsqueryApiServicesFixture; + extendedApiServices.osquery = getOsqueryApiService({ kbnClient, log }); + await use(extendedApiServices); + }, + { scope: 'worker' }, + ], +}); + +export * as testData from './constants'; diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/playwright.config.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/playwright.config.ts new file mode 100644 index 0000000000000..75a7694d12043 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/playwright.config.ts @@ -0,0 +1,12 @@ +/* + * 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 { createPlaywrightConfig } from '@kbn/scout'; + +export default createPlaywrightConfig({ + testDir: './tests', +}); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_admin.spec.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_admin.spec.ts new file mode 100644 index 0000000000000..4ee091393d72a --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_admin.spec.ts @@ -0,0 +1,61 @@ +/* + * 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 { RoleSessionCredentials } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { tags } from '@kbn/scout'; +import { apiTest, testData } from '../fixtures'; + +// TODO: run on ECH once PR #258866 makes it to prod +apiTest.describe( + 'Osquery packs - admin', + { + tag: ['@local-stateful-classic', ...tags.serverless.security.complete], + }, + () => { + let adminCredentials: RoleSessionCredentials; + const createdPackIds: string[] = []; + + apiTest.beforeAll(async ({ samlAuth }) => { + // TODO: investigate why this test only passes with cookie-based authentication while similar + // tests (saved_queries_admin.spec.ts) pass with API key-based authentication + adminCredentials = await samlAuth.asInteractiveUser('admin'); + }); + + apiTest.afterAll(async ({ apiServices }) => { + for (const packId of createdPackIds) { + await apiServices.osquery.packs.delete(packId); + } + }); + + apiTest('includes profile_uid fields on create and find', async ({ apiClient }) => { + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_PACKS, { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.cookieHeader }, + body: testData.getMinimalPack(), + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + createdPackIds.push(createResponse.body.data.saved_object_id); + + expect('created_by_profile_uid' in createResponse.body.data).toBe(true); + expect('updated_by_profile_uid' in createResponse.body.data).toBe(true); + expect(createResponse.body.data.created_by).toBeDefined(); + + const findResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_PACKS}?search=${createResponse.body.data.name}`, + { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.cookieHeader }, + responseType: 'json', + } + ); + expect(findResponse).toHaveStatusCode(200); + expect(findResponse.body.data).toBeDefined(); + expect('created_by_profile_uid' in findResponse.body.data[0]).toBe(true); + }); + } +); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_editor.spec.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_editor.spec.ts new file mode 100644 index 0000000000000..fe23db326ae99 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_editor.spec.ts @@ -0,0 +1,242 @@ +/* + * 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 { RoleApiCredentials } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { apiTest, testData } from '../fixtures'; + +// TODO: run tests on Elastic Cloud once bug fix #258883 is released +apiTest.describe( + 'Osquery packs - editor', + { tag: ['@local-stateful-classic', '@local-serverless-security_complete'] }, + () => { + let editorCredentials: RoleApiCredentials; + const createdPackIds: string[] = []; + + apiTest.beforeAll(async ({ requestAuth }) => { + editorCredentials = await requestAuth.getApiKeyForPrivilegedUser(); + }); + + apiTest.afterAll(async ({ apiServices }) => { + for (const packId of createdPackIds) { + await apiServices.osquery.packs.delete(packId); + } + }); + + apiTest('creates and reads a pack', async ({ apiClient }) => { + const packBody = testData.getMinimalPack(); + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_PACKS, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: packBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + const packId = createResponse.body.data.saved_object_id; + createdPackIds.push(packId); + + const readResponse = await apiClient.get(`${testData.API_PATHS.OSQUERY_PACKS}/${packId}`, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + }); + expect(readResponse).toHaveStatusCode(200); + expect(readResponse.body.data).toBeDefined(); + expect(readResponse.body.data.name).toBe(packBody.name); + expect(readResponse.body.data.queries.testQuery.query).toBe('select * from uptime;'); + }); + + apiTest('creates a pack with multiple queries', async ({ apiClient }) => { + const packBody = testData.getMinimalPack({ + queries: { + memoryInfo: { + query: 'SELECT * FROM memory_info;', + interval: 3600, + platform: 'linux', + }, + systemInfo: { + query: 'SELECT * FROM system_info;', + interval: 600, + }, + uptimeQuery: { + query: 'select * from uptime;', + interval: 900, + platform: 'darwin', + }, + }, + }); + + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_PACKS, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: packBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + createdPackIds.push(createResponse.body.data.saved_object_id); + + const { queries } = createResponse.body.data; + expect(Object.keys(queries)).toHaveLength(3); + expect(queries.memoryInfo.query).toBe('SELECT * FROM memory_info;'); + expect(queries.memoryInfo.platform).toBe('linux'); + expect(queries.systemInfo.interval).toBe(600); + }); + + apiTest('updates a pack', async ({ apiClient }) => { + const packBody = testData.getMinimalPack(); + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_PACKS, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: packBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + const packId = createResponse.body.data.saved_object_id; + createdPackIds.push(packId); + + const updatedName = `${packBody.name}-updated`; + const updateResponse = await apiClient.put(`${testData.API_PATHS.OSQUERY_PACKS}/${packId}`, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: { + ...packBody, + name: updatedName, + enabled: false, + queries: { + ...packBody.queries, + newQuery: { query: 'select * from processes;', interval: 1800 }, + }, + }, + responseType: 'json', + }); + expect(updateResponse).toHaveStatusCode(200); + + const readResponse = await apiClient.get(`${testData.API_PATHS.OSQUERY_PACKS}/${packId}`, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + }); + expect(readResponse).toHaveStatusCode(200); + expect(readResponse.body.data).toBeDefined(); + expect(readResponse.body.data.name).toBe(updatedName); + expect(readResponse.body.data.enabled).toBe(false); + expect(Object.keys(readResponse.body.data.queries)).toHaveLength(2); + }); + + apiTest('deletes a pack', async ({ apiClient }) => { + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_PACKS, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: testData.getMinimalPack(), + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + const packId = createResponse.body.data.saved_object_id; + + const deleteResponse = await apiClient.delete( + `${testData.API_PATHS.OSQUERY_PACKS}/${packId}`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(deleteResponse).toHaveStatusCode(200); + + const readResponse = await apiClient.get(`${testData.API_PATHS.OSQUERY_PACKS}/${packId}`, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + }); + + expect(readResponse).toHaveStatusCode(404); + }); + + apiTest('finds packs with search, enabled, and createdBy filters', async ({ apiClient }) => { + const uniquePrefix = `findtest-${Date.now()}`; + let createdByUser: string | undefined; + + for (const [suffix, enabled] of [ + ['alpha', true], + ['beta', false], + ] as const) { + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_PACKS, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: testData.getMinimalPack({ name: `${uniquePrefix}-${suffix}`, enabled }), + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + createdPackIds.push(createResponse.body.data.saved_object_id); + createdByUser ??= createResponse.body.data.created_by; + } + + expect(createdByUser).toBeDefined(); + + await apiTest.step('filters by search term', async () => { + const searchResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_PACKS}?search=${encodeURIComponent(uniquePrefix)}`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(searchResponse).toHaveStatusCode(200); + expect(searchResponse.body.total).toBe(2); + }); + + await apiTest.step('returns empty results for non-matching search', async () => { + const noMatchResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_PACKS}?search=zzzznonexistent999`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(noMatchResponse).toHaveStatusCode(200); + expect(noMatchResponse.body.total).toBe(0); + }); + + await apiTest.step('filters by enabled status', async () => { + const enabledResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_PACKS}?search=${encodeURIComponent( + uniquePrefix + )}&enabled=true`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(enabledResponse).toHaveStatusCode(200); + expect(enabledResponse.body.total).toBe(1); + expect(enabledResponse.body.data[0].name).toContain('alpha'); + + const disabledResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_PACKS}?search=${encodeURIComponent( + uniquePrefix + )}&enabled=false`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(disabledResponse).toHaveStatusCode(200); + expect(disabledResponse.body.total).toBe(1); + expect(disabledResponse.body.data[0].name).toContain('beta'); + }); + + await apiTest.step('filters by createdBy', async () => { + const createdByResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_PACKS}?search=${encodeURIComponent( + uniquePrefix + )}&createdBy=${encodeURIComponent(createdByUser!)}`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(createdByResponse).toHaveStatusCode(200); + expect(createdByResponse.body.total).toBe(2); + }); + }); + } +); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_viewer.spec.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_viewer.spec.ts new file mode 100644 index 0000000000000..7a6d35246cc4f --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/packs_viewer.spec.ts @@ -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. + */ + +import type { RoleApiCredentials } from '@kbn/scout'; +import { tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { apiTest, testData } from '../fixtures'; + +apiTest.describe( + 'Osquery packs - viewer', + { tag: [...tags.stateful.all, ...tags.serverless.security.complete] }, + () => { + let viewerCredentials: RoleApiCredentials; + let packId: string; + + apiTest.beforeAll(async ({ requestAuth, apiServices }) => { + viewerCredentials = await requestAuth.getApiKeyForViewer(); + + const response = await apiServices.osquery.packs.create(testData.getMinimalPack()); + packId = (response.data as Record>).data.saved_object_id; + }); + + apiTest.afterAll(async ({ apiServices }) => { + if (packId) { + await apiServices.osquery.packs.delete(packId); + } + }); + + apiTest('allows reading a pack', async ({ apiClient }) => { + const response = await apiClient.get(`${testData.API_PATHS.OSQUERY_PACKS}/${packId}`, { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + responseType: 'json', + }); + expect(response).toHaveStatusCode(200); + }); + + apiTest('denies pack creation', async ({ apiClient }) => { + const response = await apiClient.post(testData.API_PATHS.OSQUERY_PACKS, { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + body: testData.getMinimalPack(), + responseType: 'json', + }); + expect(response).toHaveStatusCode(403); + }); + + apiTest('denies pack update', async ({ apiClient }) => { + const response = await apiClient.put(`${testData.API_PATHS.OSQUERY_PACKS}/${packId}`, { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + body: { name: 'viewer-attempted-update', enabled: false }, + responseType: 'json', + }); + expect(response).toHaveStatusCode(403); + }); + + apiTest('denies pack deletion', async ({ apiClient }) => { + const response = await apiClient.delete(`${testData.API_PATHS.OSQUERY_PACKS}/${packId}`, { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + responseType: 'json', + }); + expect(response).toHaveStatusCode(403); + }); + } +); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/response_actions_rules.spec.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/response_actions_rules.spec.ts new file mode 100644 index 0000000000000..07cdd7b04fa49 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/response_actions_rules.spec.ts @@ -0,0 +1,292 @@ +/* + * 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 { RoleApiCredentials } from '@kbn/scout'; +import { tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { apiTest, testData } from '../fixtures'; + +apiTest.describe( + 'Detection rules with Osquery response actions', + { tag: [...tags.stateful.all, ...tags.serverless.security.complete] }, + () => { + let credentials: RoleApiCredentials; + let packSavedObjectId: string; + const createdRuleIds: string[] = []; + + apiTest.beforeAll(async ({ requestAuth, apiServices }) => { + credentials = await requestAuth.getApiKeyForPrivilegedUser(); + + const packResponse = await apiServices.osquery.packs.create( + testData.getMinimalPack({ + name: `ra-pack-${Date.now()}`, + queries: { + memoryInfo: { + query: 'SELECT * FROM memory_info;', + interval: 3600, + platform: 'linux', + }, + systemInfo: { + query: 'SELECT * FROM system_info;', + interval: 3600, + }, + }, + }) + ); + packSavedObjectId = (packResponse.data as Record>).data + .saved_object_id; + }); + + apiTest.afterAll(async ({ apiServices, kbnClient }) => { + for (const ruleId of createdRuleIds) { + await kbnClient.request({ + method: 'DELETE', + path: `/api/detection_engine/rules?id=${ruleId}`, + headers: { 'elastic-api-version': testData.OSQUERY_API_VERSION }, + ignoreErrors: [404], + }); + } + + if (packSavedObjectId) { + await apiServices.osquery.packs.delete(packSavedObjectId); + } + }); + + apiTest('creates a rule with a single Osquery query action', async ({ apiClient }) => { + const ruleBody = testData.getMinimalRule({ + response_actions: [ + { + action_type_id: '.osquery', + params: { query: 'select * from uptime;' }, + }, + ], + }); + + const createResponse = await apiClient.post(testData.API_PATHS.DETECTION_RULES, { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + body: ruleBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body).toBeDefined(); + createdRuleIds.push(createResponse.body.id); + + expect(createResponse.body.response_actions).toHaveLength(1); + expect(createResponse.body.response_actions[0]).toMatchObject({ + action_type_id: '.osquery', + params: expect.objectContaining({ query: 'select * from uptime;' }), + }); + + const getResponse = await apiClient.get( + `${testData.API_PATHS.DETECTION_RULES}?id=${createResponse.body.id}`, + { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(getResponse).toHaveStatusCode(200); + expect(getResponse.body).toBeDefined(); + expect(getResponse.body.response_actions).toHaveLength(1); + expect(getResponse.body.response_actions[0].params.query).toBe('select * from uptime;'); + }); + + apiTest( + 'creates a rule with full Osquery params including ecs_mapping and timeout', + async ({ apiClient }) => { + const ruleBody = testData.getMinimalRule({ + response_actions: [ + { + action_type_id: '.osquery', + params: { + query: 'select * from os_version;', + ecs_mapping: { 'host.os.name': { field: 'name' } }, + timeout: 120, + }, + }, + ], + }); + + const createResponse = await apiClient.post(testData.API_PATHS.DETECTION_RULES, { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + body: ruleBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body).toBeDefined(); + createdRuleIds.push(createResponse.body.id); + + const { params } = createResponse.body.response_actions[0]; + expect(params.query).toBe('select * from os_version;'); + expect(params.ecs_mapping).toStrictEqual({ 'host.os.name': { field: 'name' } }); + expect(params.timeout).toBe(120); + } + ); + + apiTest('creates a rule with pack-based Osquery action', async ({ apiClient }) => { + const ruleBody = testData.getMinimalRule({ + response_actions: [ + { + action_type_id: '.osquery', + params: { + pack_id: packSavedObjectId, + queries: [ + { + id: 'memoryInfo', + query: 'SELECT * FROM memory_info;', + interval: 3600, + platform: 'linux', + }, + { + id: 'systemInfo', + query: 'SELECT * FROM system_info;', + interval: 3600, + }, + ], + }, + }, + ], + }); + + const createResponse = await apiClient.post(testData.API_PATHS.DETECTION_RULES, { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + body: ruleBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body).toBeDefined(); + createdRuleIds.push(createResponse.body.id); + + const { params } = createResponse.body.response_actions[0]; + expect(params.pack_id).toBe(packSavedObjectId); + expect(params.queries).toHaveLength(2); + }); + + apiTest('updates a rule to add Osquery response actions', async ({ apiClient }) => { + const ruleBody = testData.getMinimalRule(); + const createResponse = await apiClient.post(testData.API_PATHS.DETECTION_RULES, { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + body: ruleBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body).toBeDefined(); + createdRuleIds.push(createResponse.body.id); + + const updateResponse = await apiClient.put(testData.API_PATHS.DETECTION_RULES, { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + body: { + ...ruleBody, + id: createResponse.body.id, + response_actions: [ + { + action_type_id: '.osquery', + params: { query: 'select * from uptime;' }, + }, + ], + }, + responseType: 'json', + }); + expect(updateResponse).toHaveStatusCode(200); + expect(updateResponse.body.response_actions).toHaveLength(1); + + const getResponse = await apiClient.get( + `${testData.API_PATHS.DETECTION_RULES}?id=${createResponse.body.id}`, + { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(getResponse).toHaveStatusCode(200); + expect(getResponse.body).toBeDefined(); + expect(getResponse.body.response_actions).toHaveLength(1); + }); + + apiTest('updates a rule to remove Osquery response actions', async ({ apiClient }) => { + const ruleBody = testData.getMinimalRule({ + response_actions: [ + { + action_type_id: '.osquery', + params: { query: 'select * from uptime;' }, + }, + ], + }); + const createResponse = await apiClient.post(testData.API_PATHS.DETECTION_RULES, { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + body: ruleBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body).toBeDefined(); + createdRuleIds.push(createResponse.body.id); + expect(createResponse.body.response_actions).toHaveLength(1); + + const updateResponse = await apiClient.put(testData.API_PATHS.DETECTION_RULES, { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + body: { + ...ruleBody, + id: createResponse.body.id, + response_actions: [], + }, + responseType: 'json', + }); + expect(updateResponse).toHaveStatusCode(200); + expect(updateResponse.body.response_actions).toHaveLength(0); + }); + + apiTest( + 'creates a rule with multiple Osquery actions of different types', + async ({ apiClient }) => { + const ruleBody = testData.getMinimalRule({ + response_actions: [ + { + action_type_id: '.osquery', + params: { query: 'select * from uptime;' }, + }, + { + action_type_id: '.osquery', + params: { + pack_id: packSavedObjectId, + queries: [ + { + id: 'memoryInfo', + query: 'SELECT * FROM memory_info;', + interval: 3600, + }, + ], + }, + }, + { + action_type_id: '.osquery', + params: { + query: 'select * from os_version;', + ecs_mapping: { 'host.os.platform': { field: 'platform' } }, + timeout: 300, + }, + }, + ], + }); + + const createResponse = await apiClient.post(testData.API_PATHS.DETECTION_RULES, { + headers: { ...testData.COMMON_HEADERS, ...credentials.apiKeyHeader }, + body: ruleBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body).toBeDefined(); + createdRuleIds.push(createResponse.body.id); + + const actions = createResponse.body.response_actions; + expect(actions).toHaveLength(3); + expect(actions[0].params.query).toBe('select * from uptime;'); + expect(actions[1].params.pack_id).toBe(packSavedObjectId); + expect(actions[2].params.ecs_mapping).toStrictEqual({ + 'host.os.platform': { field: 'platform' }, + }); + } + ); + } +); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_admin.spec.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_admin.spec.ts new file mode 100644 index 0000000000000..0b51a625ae288 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_admin.spec.ts @@ -0,0 +1,83 @@ +/* + * 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 { RoleApiCredentials } from '@kbn/scout'; +import { tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { apiTest, testData } from '../fixtures'; + +// TODO: run on ECH once #258866 is released +apiTest.describe( + 'Osquery saved queries - admin', + { tag: ['@local-stateful-classic', ...tags.serverless.security.complete] }, + () => { + let adminCredentials: RoleApiCredentials; + const createdSavedObjectIds: string[] = []; + + apiTest.beforeAll(async ({ requestAuth }) => { + adminCredentials = await requestAuth.getApiKeyForAdmin(); + }); + + apiTest.afterAll(async ({ apiServices }) => { + for (const soId of createdSavedObjectIds) { + await apiServices.osquery.savedQueries.delete(soId); + } + }); + + apiTest('includes profile_uid fields on create response', async ({ apiClient }) => { + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_SAVED_QUERIES, { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.apiKeyHeader }, + body: testData.getMinimalSavedQuery(), + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + createdSavedObjectIds.push(createResponse.body.data.saved_object_id); + + expect('created_by_profile_uid' in createResponse.body.data).toBe(true); + expect('updated_by_profile_uid' in createResponse.body.data).toBe(true); + }); + + apiTest('includes profile_uid fields on read and find responses', async ({ apiClient }) => { + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_SAVED_QUERIES, { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.apiKeyHeader }, + body: testData.getMinimalSavedQuery(), + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + const savedObjectId = createResponse.body.data.saved_object_id; + const queryId = createResponse.body.data.id; + createdSavedObjectIds.push(savedObjectId); + + const readResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(readResponse).toHaveStatusCode(200); + expect(readResponse.body.data).toBeDefined(); + expect('created_by_profile_uid' in readResponse.body.data).toBe(true); + expect('updated_by_profile_uid' in readResponse.body.data).toBe(true); + + const findResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}?page=1&pageSize=100`, + { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(findResponse).toHaveStatusCode(200); + const match = findResponse.body.data.find((q: { id: string }) => q.id === queryId); + expect(match).toBeDefined(); + expect('created_by_profile_uid' in match).toBe(true); + expect('updated_by_profile_uid' in match).toBe(true); + }); + } +); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_editor.spec.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_editor.spec.ts new file mode 100644 index 0000000000000..1414e67e4336f --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_editor.spec.ts @@ -0,0 +1,227 @@ +/* + * 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 { RoleApiCredentials } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { apiTest, testData } from '../fixtures'; + +// TODO: run tests on Elastic Cloud once bug fix #258883 is released +apiTest.describe( + 'Osquery saved queries - editor', + { tag: ['@local-stateful-classic', '@local-serverless-security_complete'] }, + () => { + let editorCredentials: RoleApiCredentials; + const createdSavedObjectIds: string[] = []; + + apiTest.beforeAll(async ({ requestAuth }) => { + editorCredentials = await requestAuth.getApiKeyForPrivilegedUser(); + }); + + apiTest.afterAll(async ({ apiServices }) => { + for (const soId of createdSavedObjectIds) { + await apiServices.osquery.savedQueries.delete(soId); + } + }); + + apiTest('creates and reads a saved query', async ({ apiClient }) => { + const queryBody = testData.getMinimalSavedQuery(); + + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_SAVED_QUERIES, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: queryBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + const savedObjectId = createResponse.body.data.saved_object_id; + createdSavedObjectIds.push(savedObjectId); + expect(createResponse.body.data.id).toBe(queryBody.id); + + const readResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(readResponse).toHaveStatusCode(200); + expect(readResponse.body.data).toBeDefined(); + expect(readResponse.body.data.id).toBe(queryBody.id); + expect(readResponse.body.data.query).toBe(queryBody.query); + }); + + apiTest('updates a saved query', async ({ apiClient }) => { + const queryBody = testData.getMinimalSavedQuery(); + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_SAVED_QUERIES, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: queryBody, + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + const savedObjectId = createResponse.body.data.saved_object_id; + createdSavedObjectIds.push(savedObjectId); + + const updatedId = `${queryBody.id}-updated`; + const updateResponse = await apiClient.put( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: { id: updatedId, query: 'select 2;', interval: 3600 }, + responseType: 'json', + } + ); + expect(updateResponse).toHaveStatusCode(200); + expect(updateResponse.body.data).toBeDefined(); + expect(updateResponse.body.data.id).toBe(updatedId); + + const readResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(readResponse).toHaveStatusCode(200); + expect(readResponse.body.data).toBeDefined(); + expect(readResponse.body.data.id).toBe(updatedId); + expect(readResponse.body.data.query).toBe('select 2;'); + }); + + apiTest('deletes a saved query', async ({ apiClient }) => { + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_SAVED_QUERIES, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: testData.getMinimalSavedQuery(), + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + const savedObjectId = createResponse.body.data.saved_object_id; + + const deleteResponse = await apiClient.delete( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(deleteResponse).toHaveStatusCode(200); + + const readResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + + expect(readResponse).toHaveStatusCode(404); + }); + + apiTest('returns 404 when reading a non-existent saved query', async ({ apiClient }) => { + const response = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/non-existent-id`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(response).toHaveStatusCode(404); + }); + + apiTest('returns 404 when updating a non-existent saved query', async ({ apiClient }) => { + const response = await apiClient.put( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/non-existent-id`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: { id: 'updated-name', query: 'select 2;', interval: 3600 }, + responseType: 'json', + } + ); + expect(response).toHaveStatusCode(404); + }); + + apiTest('returns 404 when deleting a non-existent saved query', async ({ apiClient }) => { + const response = await apiClient.delete( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/non-existent-id`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(response).toHaveStatusCode(404); + }); + + apiTest('filters by search term and createdBy', async ({ apiClient }) => { + const uniquePrefix = `findtest-${Date.now()}`; + let createdByUser: string | undefined; + + for (const suffix of ['alpha', 'beta', 'gamma']) { + const createResponse = await apiClient.post(testData.API_PATHS.OSQUERY_SAVED_QUERIES, { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + body: testData.getMinimalSavedQuery({ id: `${uniquePrefix}-${suffix}` }), + responseType: 'json', + }); + expect(createResponse).toHaveStatusCode(200); + expect(createResponse.body.data).toBeDefined(); + createdSavedObjectIds.push(createResponse.body.data.saved_object_id); + createdByUser ??= createResponse.body.data.created_by; + } + + const searchResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}?search=${uniquePrefix}-alpha`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(searchResponse).toHaveStatusCode(200); + expect(searchResponse.body.total).toBeGreaterThan(0); + const found = searchResponse.body.data.some( + (q: { id: string }) => q.id === `${uniquePrefix}-alpha` + ); + expect(found).toBe(true); + + const noMatchResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}?search=zzzznonexistent999`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(noMatchResponse).toHaveStatusCode(200); + expect(noMatchResponse.body.total).toBe(0); + + expect(createdByUser).toBeDefined(); + + const createdByResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}?createdBy=${encodeURIComponent( + createdByUser! + )}&pageSize=100`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(createdByResponse).toHaveStatusCode(200); + expect(createdByResponse.body.total).toBeGreaterThan(0); + const creators = createdByResponse.body.data.map((q: { created_by: string }) => q.created_by); + const uniqueCreators = [...new Set(creators)]; + expect(uniqueCreators).toStrictEqual([createdByUser]); + + const noUserResponse = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}?createdBy=nonexistentuser`, + { + headers: { ...testData.COMMON_HEADERS, ...editorCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(noUserResponse).toHaveStatusCode(200); + expect(noUserResponse.body.total).toBe(0); + }); + } +); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_viewer.spec.ts b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_viewer.spec.ts new file mode 100644 index 0000000000000..27840a8551f1a --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_viewer.spec.ts @@ -0,0 +1,79 @@ +/* + * 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 { RoleApiCredentials } from '@kbn/scout'; +import { tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { apiTest, testData } from '../fixtures'; + +apiTest.describe( + 'Osquery saved queries - viewer', + { tag: [...tags.stateful.all, ...tags.serverless.security.complete] }, + () => { + let viewerCredentials: RoleApiCredentials; + let savedObjectId: string; + + apiTest.beforeAll(async ({ requestAuth, apiServices }) => { + viewerCredentials = await requestAuth.getApiKeyForViewer(); + + const response = await apiServices.osquery.savedQueries.create( + testData.getMinimalSavedQuery() + ); + savedObjectId = (response.data as Record>).data + .saved_object_id; + }); + + apiTest.afterAll(async ({ apiServices }) => { + if (savedObjectId) { + await apiServices.osquery.savedQueries.delete(savedObjectId); + } + }); + + apiTest('allows reading a saved query', async ({ apiClient }) => { + const response = await apiClient.get( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(response).toHaveStatusCode(200); + }); + + apiTest('denies saved query creation', async ({ apiClient }) => { + const response = await apiClient.post(testData.API_PATHS.OSQUERY_SAVED_QUERIES, { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + body: testData.getMinimalSavedQuery(), + responseType: 'json', + }); + expect(response).toHaveStatusCode(403); + }); + + apiTest('denies saved query update', async ({ apiClient }) => { + const response = await apiClient.put( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + body: { id: 'viewer-attempted-update', query: 'select 99;', interval: 3600 }, + responseType: 'json', + } + ); + expect(response).toHaveStatusCode(403); + }); + + apiTest('denies saved query deletion', async ({ apiClient }) => { + const response = await apiClient.delete( + `${testData.API_PATHS.OSQUERY_SAVED_QUERIES}/${savedObjectId}`, + { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + responseType: 'json', + } + ); + expect(response).toHaveStatusCode(403); + }); + } +); diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/common/constants.ts b/x-pack/platform/plugins/shared/osquery/test/scout/common/constants.ts new file mode 100644 index 0000000000000..d87b67ae60d80 --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/common/constants.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export const OSQUERY_API_VERSION = '2023-10-31'; diff --git a/x-pack/platform/plugins/shared/osquery/test/scout/common/services/osquery_api_service.ts b/x-pack/platform/plugins/shared/osquery/test/scout/common/services/osquery_api_service.ts new file mode 100644 index 0000000000000..d4494b82b3dea --- /dev/null +++ b/x-pack/platform/plugins/shared/osquery/test/scout/common/services/osquery_api_service.ts @@ -0,0 +1,90 @@ +/* + * 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 { KbnClient, ScoutLogger } from '@kbn/scout'; +import { measurePerformanceAsync } from '@kbn/scout'; +import { OSQUERY_API_VERSION } from '../constants'; + +const OSQUERY_PACKS_URL = '/api/osquery/packs'; +const OSQUERY_SAVED_QUERIES_URL = '/api/osquery/saved_queries'; + +export interface OsqueryApiService { + packs: { + create: (body: Record) => Promise; + delete: (id: string) => Promise; + }; + savedQueries: { + create: (body: Record) => Promise; + delete: (id: string) => Promise; + }; +} + +export const getOsqueryApiService = ({ + kbnClient, + log, +}: { + kbnClient: KbnClient; + log: ScoutLogger; +}): OsqueryApiService => { + const headers = { + 'elastic-api-version': OSQUERY_API_VERSION, + }; + + return { + packs: { + create: async (body: Record) => + await measurePerformanceAsync( + log, + 'osquery.packs.create', + async () => + await kbnClient.request({ + method: 'POST', + path: OSQUERY_PACKS_URL, + headers, + body, + }) + ), + + delete: async (id: string) => { + await measurePerformanceAsync(log, `osquery.packs.delete [${id}]`, async () => { + await kbnClient.request({ + method: 'DELETE', + path: `${OSQUERY_PACKS_URL}/${id}`, + headers, + ignoreErrors: [404], + }); + }); + }, + }, + + savedQueries: { + create: async (body: Record) => + await measurePerformanceAsync( + log, + 'osquery.savedQueries.create', + async () => + await kbnClient.request({ + method: 'POST', + path: OSQUERY_SAVED_QUERIES_URL, + headers, + body, + }) + ), + + delete: async (id: string) => { + await measurePerformanceAsync(log, `osquery.savedQueries.delete [${id}]`, async () => { + await kbnClient.request({ + method: 'DELETE', + path: `${OSQUERY_SAVED_QUERIES_URL}/${id}`, + headers, + ignoreErrors: [404], + }); + }); + }, + }, + }; +}; diff --git a/x-pack/platform/plugins/shared/osquery/tsconfig.json b/x-pack/platform/plugins/shared/osquery/tsconfig.json index 10e6889435af6..5fb84d2c97d1b 100644 --- a/x-pack/platform/plugins/shared/osquery/tsconfig.json +++ b/x-pack/platform/plugins/shared/osquery/tsconfig.json @@ -11,6 +11,7 @@ "scripts/**/*", "scripts/**/*.json", "server/**/*", + "test/scout/**/*", "../../../../../typings/**/*", // ECS and Osquery schema files "public/common/schemas/*/*.json" @@ -92,6 +93,7 @@ "@kbn/ui-actions-plugin", "@kbn/unified-search-plugin", "@kbn/core-notifications-browser", - "@kbn/core-chrome-browser" + "@kbn/core-chrome-browser", + "@kbn/scout" ] } diff --git a/x-pack/platform/test/api_integration/apis/osquery/saved_queries.ts b/x-pack/platform/test/api_integration/apis/osquery/saved_queries.ts index 49b9fd02b1b0e..3965141ca8891 100644 --- a/x-pack/platform/test/api_integration/apis/osquery/saved_queries.ts +++ b/x-pack/platform/test/api_integration/apis/osquery/saved_queries.ts @@ -8,6 +8,11 @@ import expect from '@kbn/expect'; import type { FtrProviderContext } from '../../ftr_provider_context'; +// Only the "users route" tests remain here — they require the queryHistoryRework +// experimental flag (enabled in config.ts) which registers the internal endpoint. +// All other saved-query tests have been migrated to Scout: +// x-pack/platform/plugins/shared/osquery/test/scout/api/tests/saved_queries_*.spec.ts + export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const osqueryPublicApiVersion = '2023-10-31'; @@ -23,108 +28,13 @@ export default function ({ getService }: FtrProviderContext) { interval: '3600', }); - const getSavedQuery = (savedObjectId: string) => - supertest - .get(`/api/osquery/saved_queries/${savedObjectId}`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', osqueryPublicApiVersion); - const deleteSavedQuery = (savedObjectId: string) => supertest .delete(`/api/osquery/saved_queries/${savedObjectId}`) .set('kbn-xsrf', 'true') .set('elastic-api-version', osqueryPublicApiVersion); - const updateSavedQuery = (savedObjectId: string, id: string) => - supertest - .put(`/api/osquery/saved_queries/${savedObjectId}`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', osqueryPublicApiVersion) - .send({ - id, - query: 'select 2;', - interval: 3600, - }); - - const findSavedQueries = () => - supertest - .get('/api/osquery/saved_queries?page=1&pageSize=20') - .set('kbn-xsrf', 'true') - .set('elastic-api-version', osqueryPublicApiVersion); - - const findSavedQueriesWithParams = (params: string) => - supertest - .get(`/api/osquery/saved_queries?${params}`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', osqueryPublicApiVersion); - describe('Saved queries', () => { - it('creates, reads, and deletes a saved query', async () => { - const savedQueryId = `saved-query-${Date.now()}`; - - const createResponse = await createSavedQuery(savedQueryId); - expect(createResponse.status).to.be(200); - const savedObjectId = createResponse.body.data.saved_object_id; - - const readResponse = await getSavedQuery(savedObjectId); - expect(readResponse.status).to.be(200); - expect(readResponse.body.data.id).to.be(savedQueryId); - - const updatedSavedQueryId = `${savedQueryId}-updated`; - const updateResponse = await updateSavedQuery(savedObjectId, updatedSavedQueryId); - expect(updateResponse.status).to.be(200); - expect(updateResponse.body.data.id).to.be(updatedSavedQueryId); - - const findResponse = await findSavedQueries(); - expect(findResponse.status).to.be(200); - expect( - findResponse.body.data.some( - (savedQuery: { id: string }) => savedQuery.id === updatedSavedQueryId - ) - ).to.be(true); - - const deleteResponse = await deleteSavedQuery(savedObjectId); - expect(deleteResponse.status).to.be(200); - }); - - describe('profile_uid fields', () => { - let savedObjectId: string; - const queryId = `profile-uid-query-${Date.now()}`; - - before(async () => { - const response = await createSavedQuery(queryId).expect(200); - savedObjectId = response.body.data.saved_object_id; - }); - - after(async () => { - if (savedObjectId) { - await deleteSavedQuery(savedObjectId); - } - }); - - it('includes profile_uid fields on create response', async () => { - const response = await createSavedQuery(`profile-uid-query-2-${Date.now()}`).expect(200); - const { data } = response.body; - expect(data).to.have.property('created_by_profile_uid'); - expect(data).to.have.property('updated_by_profile_uid'); - await deleteSavedQuery(data.saved_object_id); - }); - - it('includes profile_uid fields on read response', async () => { - const response = await getSavedQuery(savedObjectId).expect(200); - expect(response.body.data).to.have.property('created_by_profile_uid'); - expect(response.body.data).to.have.property('updated_by_profile_uid'); - }); - - it('includes profile_uid fields in find response', async () => { - const response = await findSavedQueries().expect(200); - const match = response.body.data.find((q: { id: string }) => q.id === queryId); - expect(match).to.be.ok(); - expect(match).to.have.property('created_by_profile_uid'); - expect(match).to.have.property('updated_by_profile_uid'); - }); - }); - describe('users route', () => { const usersPrefix = `users-query-${Date.now()}`; const savedObjectIds: string[] = []; @@ -155,7 +65,6 @@ export default function ({ getService }: FtrProviderContext) { const users = response.body.data.map((c: { created_by: string }) => c.created_by); expect(users).to.contain('elastic'); - // Verify uniqueness const uniqueUsers = [...new Set(users)]; expect(uniqueUsers.length).to.be(users.length); }); @@ -174,70 +83,5 @@ export default function ({ getService }: FtrProviderContext) { expect(elastic).to.have.property('created_by'); }); }); - - describe('find with search and createdBy params', () => { - const uniquePrefix = `findtest-${Date.now()}`; - const queryIds: string[] = []; - const savedObjectIds: string[] = []; - - before(async () => { - for (const suffix of ['alpha', 'beta', 'gamma']) { - const id = `${uniquePrefix}-${suffix}`; - const response = await createSavedQuery(id).expect(200); - queryIds.push(id); - savedObjectIds.push(response.body.data.saved_object_id); - } - }); - - after(async () => { - for (const soId of savedObjectIds) { - await deleteSavedQuery(soId); - } - }); - - it('filters by search term matching query id', async () => { - const response = await findSavedQueriesWithParams(`search=${uniquePrefix}-alpha`).expect( - 200 - ); - expect(response.body.total).to.be.greaterThan(0); - expect( - response.body.data.some((q: { id: string }) => q.id === `${uniquePrefix}-alpha`) - ).to.be(true); - }); - - it('returns empty results for non-matching search', async () => { - const response = await findSavedQueriesWithParams('search=zzzznonexistent999').expect(200); - expect(response.body.total).to.be(0); - }); - - it('filters by createdBy', async () => { - const response = await findSavedQueriesWithParams(`createdBy=elastic&pageSize=100`).expect( - 200 - ); - expect(response.body.total).to.be.greaterThan(0); - const creators = response.body.data.map((q: { created_by: string }) => q.created_by); - const uniqueCreators = [...new Set(creators)]; - expect(uniqueCreators).to.eql(['elastic']); - }); - - it('returns empty results for non-matching createdBy', async () => { - const response = await findSavedQueriesWithParams('createdBy=nonexistentuser').expect(200); - expect(response.body.total).to.be(0); - }); - }); - - describe('404 for non-existent resources', () => { - it('returns 404 when reading a non-existent saved query', async () => { - await getSavedQuery('non-existent-id').expect(404); - }); - - it('returns 404 when updating a non-existent saved query', async () => { - await updateSavedQuery('non-existent-id', 'updated-name').expect(404); - }); - - it('returns 404 when deleting a non-existent saved query', async () => { - await deleteSavedQuery('non-existent-id').expect(404); - }); - }); }); }