Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions x-pack/platform/plugins/shared/osquery/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ dependsOn:
- '@kbn/core-notifications-browser'
- '@kbn/core-chrome-browser'
- '@kbn/scout'
- '@kbn/core-http-server-mocks'
tags:
- plugin
- prod
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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 { httpServerMock } from '@kbn/core-http-server-mocks';
import type { CoreStart, KibanaRequest } from '@kbn/core/server';
import { checkOsqueryResponseActionAuthz } from './check_response_action_authz';

describe('checkOsqueryResponseActionAuthz', () => {
let request: KibanaRequest;
let mockCoreStart: CoreStart;

const createMockCoreStart = (capabilities: Record<string, boolean>): CoreStart =>
({
capabilities: {
resolveCapabilities: jest.fn().mockResolvedValue({
osquery: capabilities,
}),
},
} as unknown as CoreStart);

beforeEach(() => {
request = httpServerMock.createKibanaRequest();
});

it('should return true when user has writeLiveQueries', async () => {
mockCoreStart = createMockCoreStart({ writeLiveQueries: true, runSavedQueries: false });

const result = await checkOsqueryResponseActionAuthz(mockCoreStart, request, {});
expect(result).toBe(true);
});

it('should return false when user lacks writeLiveQueries for direct query', async () => {
mockCoreStart = createMockCoreStart({ writeLiveQueries: false, runSavedQueries: false });

const result = await checkOsqueryResponseActionAuthz(mockCoreStart, request, {});
expect(result).toBe(false);
});

it('should return true when user has runSavedQueries with saved_query_id', async () => {
mockCoreStart = createMockCoreStart({ writeLiveQueries: false, runSavedQueries: true });

const result = await checkOsqueryResponseActionAuthz(mockCoreStart, request, {
saved_query_id: 'test-query-id',
});
expect(result).toBe(true);
});

it('should return true when user has runSavedQueries with pack_id', async () => {
mockCoreStart = createMockCoreStart({ writeLiveQueries: false, runSavedQueries: true });

const result = await checkOsqueryResponseActionAuthz(mockCoreStart, request, {
pack_id: 'test-pack-id',
});
expect(result).toBe(true);
});

it('should return false when user has runSavedQueries but no saved_query_id or pack_id', async () => {
mockCoreStart = createMockCoreStart({ writeLiveQueries: false, runSavedQueries: true });

const result = await checkOsqueryResponseActionAuthz(mockCoreStart, request, {});
expect(result).toBe(false);
});

it('should return false when user lacks both privileges with saved_query_id', async () => {
mockCoreStart = createMockCoreStart({ writeLiveQueries: false, runSavedQueries: false });

const result = await checkOsqueryResponseActionAuthz(mockCoreStart, request, {
saved_query_id: 'test-query-id',
});
expect(result).toBe(false);
});

it('should return true when user has writeLiveQueries regardless of saved_query_id', async () => {
mockCoreStart = createMockCoreStart({ writeLiveQueries: true, runSavedQueries: false });

const result = await checkOsqueryResponseActionAuthz(mockCoreStart, request, {
saved_query_id: 'test-query-id',
});
expect(result).toBe(true);
});

it('should call resolveCapabilities with the correct request and path', async () => {
mockCoreStart = createMockCoreStart({ writeLiveQueries: true, runSavedQueries: false });

await checkOsqueryResponseActionAuthz(mockCoreStart, request, {});

expect(mockCoreStart.capabilities.resolveCapabilities).toHaveBeenCalledWith(request, {
capabilityPath: 'osquery.*',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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 { CoreStart, KibanaRequest } from '@kbn/core/server';
import type { CheckResponseActionAuthzParams } from '../types';

/**
* Checks whether the requesting user has the required osquery privileges
* for a given action configuration.
*
* Privilege logic (mirrors create_live_query_route):
* - Direct query → needs writeLiveQueries
* - Saved query / pack → needs writeLiveQueries OR runSavedQueries
*
* @returns true if authorized, false otherwise
*/
export const checkOsqueryResponseActionAuthz = async (
coreStart: CoreStart,
request: KibanaRequest,
actionParams: CheckResponseActionAuthzParams
): Promise<boolean> => {
const {
osquery: { writeLiveQueries, runSavedQueries },
} = await coreStart.capabilities.resolveCapabilities(request, {
capabilityPath: 'osquery.*',
});

return !!(
writeLiveQueries ||
(runSavedQueries && (actionParams.saved_query_id || actionParams.pack_id))
);
};
19 changes: 19 additions & 0 deletions x-pack/platform/plugins/shared/osquery/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type {
KibanaRequest,
PluginInitializerContext,
CoreSetup,
CoreStart,
Expand Down Expand Up @@ -49,6 +50,8 @@ import { registerFeatures } from './utils/register_features';
import { CASE_ATTACHMENT_TYPE_ID } from '../common/constants';
import { createActionService } from './handlers/action/create_action_service';
import { backfillScheduleIds } from './lib/backfill_schedule_ids';
import { checkOsqueryResponseActionAuthz } from './lib/check_response_action_authz';
import { CustomHttpRequestError } from './common/error';
import { SchemaService } from './lib/schema_service';

const BACKFILL_TASK_TYPE = 'osquery:backfillScheduleIds';
Expand Down Expand Up @@ -146,8 +149,24 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt

plugins.cases?.attachmentFramework.registerExternalReference({ id: CASE_ATTACHMENT_TYPE_ID });

const checkResponseActionAuthz = async (
request: KibanaRequest,
actionParams: { saved_query_id?: string; pack_id?: string }
): Promise<void> => {
const [coreStart] = await core.getStartServices();
const isAuthorized = await checkOsqueryResponseActionAuthz(coreStart, request, actionParams);

if (!isAuthorized) {
throw new CustomHttpRequestError(
'User is not authorized to create/update osquery response action',
403
);
}
};

return {
createActionService: this.createActionService,
checkResponseActionAuthz,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { StartPlugins } from '../../types';
import { createActionHandler } from '../../handlers';
import { parser as OsqueryParser } from './osquery_parser';
import { getUserInfo } from '../../lib/get_user_info';
import { checkOsqueryResponseActionAuthz } from '../../lib/check_response_action_authz';

export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => {
router.versioned
Expand Down Expand Up @@ -52,16 +53,10 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp
async (context, request, response) => {
const [coreStartServices, startPlugins] = await osqueryContext.getStartServices();

const {
osquery: { writeLiveQueries, runSavedQueries },
} = await coreStartServices.capabilities.resolveCapabilities(request, {
capabilityPath: 'osquery.*',
});

const isInvalid = !(
writeLiveQueries ||
(runSavedQueries && (request.body.saved_query_id || request.body.pack_id))
);
const isInvalid = !(await checkOsqueryResponseActionAuthz(coreStartServices, request, {
saved_query_id: request.body.saved_query_id,
pack_id: request.body.pack_id,
}));

const client = await osqueryContext.service
.getRuleRegistryService()
Expand Down
18 changes: 18 additions & 0 deletions x-pack/platform/plugins/shared/osquery/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,29 @@ import type { PluginStart as DataViewsPluginStart } from '@kbn/data-views-plugin
import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server';
import type { CasesServerSetup } from '@kbn/cases-plugin/server';
import type { LicensingPluginSetup } from '@kbn/licensing-plugin/server';
import type { KibanaRequest } from '@kbn/core/server';
import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server';
import type { createActionService } from './handlers/action/create_action_service';

export interface CheckResponseActionAuthzParams {
saved_query_id?: string;
pack_id?: string;
}

export interface OsqueryPluginSetup {
createActionService: ReturnType<typeof createActionService>;
/**
* Validates that the requesting user has the required osquery privileges
* for the given response action configuration.
* Throws a 403 CustomHttpRequestError if the user lacks authorization.
*
* Used by security_solution when creating/updating detection rules
* that include osquery response actions.
*/
checkResponseActionAuthz: (
request: KibanaRequest,
actionParams: CheckResponseActionAuthzParams
) => Promise<void>;
}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
Expand Down
3 changes: 2 additions & 1 deletion x-pack/platform/plugins/shared/osquery/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"@kbn/unified-search-plugin",
"@kbn/core-notifications-browser",
"@kbn/core-chrome-browser",
"@kbn/scout"
"@kbn/scout",
"@kbn/core-http-server-mocks"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,20 @@ describe('Rules Endpoint response actions validators', () => {
await expect(validateRuleResponseActions(options)).resolves.toBeUndefined();
});

it('should only validate .endpoint response actions', async () => {
it('should validate both .endpoint and .osquery response actions independently', async () => {
endpointAuthz.canIsolateHost = false;
const mockOsqueryAuthz = jest.fn().mockResolvedValue(undefined);
rulePayload.response_actions = [
{ action_type_id: '.osquery', params: {} },
{ action_type_id: '.osquery', params: { query: 'SELECT 1' } },
createRulePayloadResponseActionMock(),
];
options.checkOsqueryResponseActionAuthz = mockOsqueryAuthz;

await expect(validateRuleResponseActions(options)).rejects.toThrow(
'User is not authorized to create/update isolate response action'
);
// Osquery authz was still called for the osquery action
expect(mockOsqueryAuthz).toHaveBeenCalledTimes(1);
});

interface AuthzTestCase {
Expand Down Expand Up @@ -496,4 +500,108 @@ describe('Rules Endpoint response actions validators', () => {
`);
});
});

describe('osquery response actions validation', () => {
let options: ValidateRuleResponseActionsOptions;
let mockOsqueryAuthz: jest.Mock;

beforeEach(() => {
mockOsqueryAuthz = jest.fn().mockResolvedValue(undefined);
options = {
rulePayload: {
response_actions: [
{
action_type_id: '.osquery' as const,
params: { query: 'SELECT * FROM processes' },
},
],
},
endpointService,
endpointAuthz,
spaceId: 'foo',
checkOsqueryResponseActionAuthz: mockOsqueryAuthz,
};
});

it('should call osquery authz checker for .osquery actions', async () => {
await validateRuleResponseActions(options);

expect(mockOsqueryAuthz).toHaveBeenCalledWith({
saved_query_id: undefined,
pack_id: undefined,
});
});

it('should pass saved_query_id to osquery authz checker', async () => {
options.rulePayload = {
response_actions: [
{
action_type_id: '.osquery' as const,
params: { saved_query_id: 'test-saved-query' },
},
],
};

await validateRuleResponseActions(options);

expect(mockOsqueryAuthz).toHaveBeenCalledWith({
saved_query_id: 'test-saved-query',
pack_id: undefined,
});
});

it('should pass pack_id to osquery authz checker', async () => {
options.rulePayload = {
response_actions: [
{
action_type_id: '.osquery' as const,
params: { pack_id: 'test-pack' },
},
],
};

await validateRuleResponseActions(options);

expect(mockOsqueryAuthz).toHaveBeenCalledWith({
saved_query_id: undefined,
pack_id: 'test-pack',
});
});

it('should throw when osquery authz checker rejects', async () => {
const authzError: Error & { statusCode?: number } = new Error(
'User is not authorized to create/update osquery response action'
);
authzError.statusCode = 403;
mockOsqueryAuthz.mockRejectedValue(authzError);

await expect(validateRuleResponseActions(options)).rejects.toThrow(
'User is not authorized to create/update osquery response action'
);
});

it('should skip osquery validation when no authz checker is provided', async () => {
delete options.checkOsqueryResponseActionAuthz;

// Should not throw even though there are osquery actions
await expect(validateRuleResponseActions(options)).resolves.toBeUndefined();
});

it('should not validate unchanged osquery actions on update', async () => {
const osqueryAction = {
action_type_id: '.osquery' as const,
params: { query: 'SELECT * FROM processes' },
};
options.rulePayload = { response_actions: [osqueryAction] };
existingRule.params.responseActions = [
{ actionTypeId: '.osquery', params: { query: 'SELECT * FROM processes' } },
] as RuleResponseAction[];
options.existingRule = existingRule;

await validateRuleResponseActions(options);

// Osquery authz should not be called since the action is unchanged
expect(mockOsqueryAuthz).not.toHaveBeenCalled();
});
});
});
Loading
Loading