mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
### Summary Went back to add audit logging to the alerts table search strategy used to query RAC alerts. This PR also includes tests for the logging. Co-authored-by: Yara Tercero <yctercero@users.noreply.github.com>
This commit is contained in:
parent
9074bf3882
commit
e6f52d9467
9 changed files with 181 additions and 20 deletions
|
@ -144,6 +144,10 @@ Refer to the corresponding {es} logs for potential write errors.
|
|||
| `unknown` | User is updating a space.
|
||||
| `failure` | User is not authorized to update a space.
|
||||
|
||||
.2+| `alert_update`
|
||||
| `unknown` | User is updating an alert.
|
||||
| `failure` | User is not authorized to update an alert.
|
||||
|
||||
3+a|
|
||||
====== Type: deletion
|
||||
|
||||
|
@ -214,6 +218,14 @@ Refer to the corresponding {es} logs for potential write errors.
|
|||
| `success` | User has accessed a space as part of a search operation.
|
||||
| `failure` | User is not authorized to search for spaces.
|
||||
|
||||
.2+| `alert_get`
|
||||
| `success` | User has accessed an alert.
|
||||
| `failure` | User is not authorized to access an alert.
|
||||
|
||||
.2+| `alert_find`
|
||||
| `success` | User has accessed an alert as part of a search operation.
|
||||
| `failure` | User is not authorized to access alerts.
|
||||
|
||||
3+a|
|
||||
===== Category: web
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ export type {
|
|||
export * from './config';
|
||||
export * from './rule_data_plugin_service';
|
||||
export * from './rule_data_client';
|
||||
export * from './alert_data_client/audit_events';
|
||||
|
||||
export { createLifecycleRuleTypeFactory } from './utils/create_lifecycle_rule_type_factory';
|
||||
export {
|
||||
|
|
|
@ -11,5 +11,5 @@
|
|||
"server": true,
|
||||
"ui": true,
|
||||
"requiredPlugins": ["alerting", "cases", "data", "dataEnhanced", "kibanaReact", "kibanaUtils"],
|
||||
"optionalPlugins": []
|
||||
"optionalPlugins": ["security"]
|
||||
}
|
||||
|
|
|
@ -18,11 +18,13 @@ import { defineRoutes } from './routes';
|
|||
import { timelineSearchStrategyProvider } from './search_strategy/timeline';
|
||||
import { timelineEqlSearchStrategyProvider } from './search_strategy/timeline/eql';
|
||||
import { indexFieldsProvider } from './search_strategy/index_fields';
|
||||
import { SecurityPluginSetup } from '../../security/server';
|
||||
|
||||
export class TimelinesPlugin
|
||||
implements Plugin<TimelinesPluginUI, TimelinesPluginStart, SetupPlugins, StartPlugins>
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
private security?: SecurityPluginSetup;
|
||||
|
||||
constructor(initializerContext: PluginInitializerContext) {
|
||||
this.logger = initializerContext.logger.get();
|
||||
|
@ -30,6 +32,8 @@ export class TimelinesPlugin
|
|||
|
||||
public setup(core: CoreSetup<StartPlugins, TimelinesPluginStart>, plugins: SetupPlugins) {
|
||||
this.logger.debug('timelines: Setup');
|
||||
this.security = plugins.security;
|
||||
|
||||
const router = core.http.createRouter();
|
||||
|
||||
// Register server side APIs
|
||||
|
@ -39,7 +43,8 @@ export class TimelinesPlugin
|
|||
core.getStartServices().then(([_, depsStart]) => {
|
||||
const TimelineSearchStrategy = timelineSearchStrategyProvider(
|
||||
depsStart.data,
|
||||
depsStart.alerting
|
||||
depsStart.alerting,
|
||||
this.security
|
||||
);
|
||||
const TimelineEqlSearchStrategy = timelineEqlSearchStrategyProvider(depsStart.data);
|
||||
const IndexFields = indexFieldsProvider();
|
||||
|
|
|
@ -32,16 +32,20 @@ import {
|
|||
ENHANCED_ES_SEARCH_STRATEGY,
|
||||
ISearchOptions,
|
||||
} from '../../../../../../src/plugins/data/common';
|
||||
import { AuditLogger, SecurityPluginSetup } from '../../../../security/server';
|
||||
import { AlertAuditAction, alertAuditEvent } from '../../../../rule_registry/server';
|
||||
|
||||
export const timelineSearchStrategyProvider = <T extends TimelineFactoryQueryTypes>(
|
||||
data: PluginStart,
|
||||
alerting: AlertingPluginStartContract
|
||||
alerting: AlertingPluginStartContract,
|
||||
security?: SecurityPluginSetup
|
||||
): ISearchStrategy<TimelineStrategyRequestType<T>, TimelineStrategyResponseType<T>> => {
|
||||
const esAsInternal = data.search.searchAsInternalUser;
|
||||
const es = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY);
|
||||
|
||||
return {
|
||||
search: (request, options, deps) => {
|
||||
const securityAuditLogger = security?.audit.asScoped(deps.request);
|
||||
const factoryQueryType = request.factoryQueryType;
|
||||
const entityType = request.entityType;
|
||||
|
||||
|
@ -59,6 +63,7 @@ export const timelineSearchStrategyProvider = <T extends TimelineFactoryQueryTyp
|
|||
deps,
|
||||
queryFactory,
|
||||
alerting,
|
||||
auditLogger: securityAuditLogger,
|
||||
});
|
||||
} else {
|
||||
return timelineSearchStrategy({ es, request, options, deps, queryFactory });
|
||||
|
@ -104,6 +109,7 @@ const timelineAlertsSearchStrategy = <T extends TimelineFactoryQueryTypes>({
|
|||
deps,
|
||||
queryFactory,
|
||||
alerting,
|
||||
auditLogger,
|
||||
}: {
|
||||
es: ISearchStrategy;
|
||||
request: TimelineStrategyRequestType<T>;
|
||||
|
@ -111,9 +117,8 @@ const timelineAlertsSearchStrategy = <T extends TimelineFactoryQueryTypes>({
|
|||
deps: SearchStrategyDependencies;
|
||||
alerting: AlertingPluginStartContract;
|
||||
queryFactory: TimelineFactory<T>;
|
||||
auditLogger: AuditLogger | undefined;
|
||||
}) => {
|
||||
// Based on what solution alerts you want to see, figures out what corresponding
|
||||
// index to query (ex: siem --> .alerts-security.alerts)
|
||||
const indices = request.defaultIndex ?? request.indexType;
|
||||
const requestWithAlertsIndices = { ...request, defaultIndex: indices, indexName: indices };
|
||||
|
||||
|
@ -133,17 +138,46 @@ const timelineAlertsSearchStrategy = <T extends TimelineFactoryQueryTypes>({
|
|||
|
||||
return from(getAuthFilter()).pipe(
|
||||
mergeMap(({ filter }) => {
|
||||
const dsl = queryFactory.buildDsl({ ...requestWithAlertsIndices, authFilter: filter });
|
||||
const dsl = queryFactory.buildDsl({
|
||||
...requestWithAlertsIndices,
|
||||
authFilter: filter,
|
||||
});
|
||||
return es.search({ ...requestWithAlertsIndices, params: dsl }, options, deps);
|
||||
}),
|
||||
map((response) => {
|
||||
const rawResponse = shimHitsTotal(response.rawResponse, options);
|
||||
// Do we have to loop over each hit? Yes.
|
||||
// ecs auditLogger requires that we log each alert independently
|
||||
if (auditLogger != null) {
|
||||
rawResponse.hits?.hits?.forEach((hit) => {
|
||||
auditLogger.log(
|
||||
alertAuditEvent({
|
||||
action: AlertAuditAction.FIND,
|
||||
id: hit._id,
|
||||
outcome: 'success',
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
rawResponse: shimHitsTotal(response.rawResponse, options),
|
||||
rawResponse,
|
||||
};
|
||||
}),
|
||||
mergeMap((esSearchRes) => queryFactory.parse(requestWithAlertsIndices, esSearchRes)),
|
||||
catchError((err) => {
|
||||
// check if auth error, if yes, write to ecs logger
|
||||
if (auditLogger != null && err?.output?.statusCode === 403) {
|
||||
auditLogger.log(
|
||||
alertAuditEvent({
|
||||
action: AlertAuditAction.FIND,
|
||||
outcome: 'failure',
|
||||
error: err,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { DataPluginSetup, DataPluginStart } from '../../../../src/plugins/data/server/plugin';
|
||||
import { PluginStartContract as AlertingPluginStartContract } from '../../alerting/server';
|
||||
import { SecurityPluginSetup } from '../../security/server';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface TimelinesPluginUI {}
|
||||
|
@ -16,6 +17,7 @@ export interface TimelinesPluginStart {}
|
|||
|
||||
export interface SetupPlugins {
|
||||
data: DataPluginSetup;
|
||||
security?: SecurityPluginSetup;
|
||||
}
|
||||
|
||||
export interface StartPlugins {
|
||||
|
|
|
@ -71,6 +71,10 @@ const onlyNotInCoverageTests = [
|
|||
require.resolve('../test/saved_object_api_integration/security_and_spaces/config_trial.ts'),
|
||||
require.resolve('../test/saved_object_api_integration/security_and_spaces/config_basic.ts'),
|
||||
require.resolve('../test/saved_object_api_integration/spaces_only/config.ts'),
|
||||
// TODO: Enable once RBAC timeline search strategy
|
||||
// tests updated
|
||||
// require.resolve('../test/timeline/security_and_spaces/config_basic.ts'),
|
||||
require.resolve('../test/timeline/security_and_spaces/config_trial.ts'),
|
||||
require.resolve('../test/ui_capabilities/security_and_spaces/config.ts'),
|
||||
require.resolve('../test/ui_capabilities/spaces_only/config.ts'),
|
||||
require.resolve('../test/upgrade_assistant_integration/config.js'),
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { CA_CERT_PATH } from '@kbn/dev-utils';
|
||||
import { FtrConfigProviderContext } from '@kbn/test';
|
||||
import { resolve } from 'path';
|
||||
|
||||
import { services } from './services';
|
||||
import { getAllExternalServiceSimulatorPaths } from '../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin';
|
||||
|
@ -40,6 +41,7 @@ const enabledActionTypes = [
|
|||
|
||||
export function createTestConfig(name: string, options: CreateTestConfigOptions) {
|
||||
const { license = 'trial', disabledPlugins = [], ssl = false, testFiles = [] } = options;
|
||||
const auditLogPath = resolve(__dirname, './audit.log');
|
||||
|
||||
return async ({ readConfigFile }: FtrConfigProviderContext) => {
|
||||
const xPackApiIntegrationTestsConfig = await readConfigFile(
|
||||
|
@ -85,6 +87,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
|
|||
// TO DO: Remove feature flags once we're good to go
|
||||
'--xpack.securitySolution.enableExperimental=["ruleRegistryEnabled"]',
|
||||
'--xpack.ruleRegistry.write.enabled=true',
|
||||
'--xpack.security.audit.enabled=true',
|
||||
'--xpack.security.audit.appender.type=file',
|
||||
`--xpack.security.audit.appender.fileName=${auditLogPath}`,
|
||||
'--xpack.security.audit.appender.layout.type=json',
|
||||
`--server.xsrf.allowlist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`,
|
||||
...(ssl
|
||||
? [
|
||||
|
|
|
@ -5,9 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import Path from 'path';
|
||||
import Fs from 'fs';
|
||||
import { JsonObject } from '@kbn/utility-types';
|
||||
import expect from '@kbn/expect';
|
||||
import { ALERT_UUID, ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils';
|
||||
import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils';
|
||||
|
||||
import { User } from '../../../../rule_registry/common/lib/authentication/types';
|
||||
import { TimelineEdges, TimelineNonEcsData } from '../../../../../plugins/timelines/common/';
|
||||
|
@ -18,6 +20,7 @@ import {
|
|||
obsMinReadAlertsReadSpacesAll,
|
||||
obsMinRead,
|
||||
obsMinReadSpacesAll,
|
||||
superUser,
|
||||
} from '../../../../rule_registry/common/lib/authentication/users';
|
||||
import {
|
||||
Direction,
|
||||
|
@ -25,6 +28,28 @@ import {
|
|||
} from '../../../../../plugins/security_solution/common/search_strategy';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
|
||||
class FileWrapper {
|
||||
constructor(private readonly path: string) {}
|
||||
async reset() {
|
||||
// "touch" each file to ensure it exists and is empty before each test
|
||||
await Fs.promises.writeFile(this.path, '');
|
||||
}
|
||||
async read() {
|
||||
const content = await Fs.promises.readFile(this.path, { encoding: 'utf8' });
|
||||
return content.trim().split('\n');
|
||||
}
|
||||
async readJSON() {
|
||||
const content = await this.read();
|
||||
return content.map((l) => JSON.parse(l));
|
||||
}
|
||||
// writing in a file is an async operation. we use this method to make sure logs have been written.
|
||||
async isNotEmpty() {
|
||||
const content = await this.read();
|
||||
const line = content[0];
|
||||
return line.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
interface TestCase {
|
||||
/** The space where the alert exists */
|
||||
space?: string;
|
||||
|
@ -44,6 +69,7 @@ const TO = '3000-01-01T00:00:00.000Z';
|
|||
const FROM = '2000-01-01T00:00:00.000Z';
|
||||
const TEST_URL = '/internal/search/timelineSearchStrategy/';
|
||||
const SPACE_1 = 'space1';
|
||||
const SPACE_2 = 'space2';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ({ getService }: FtrProviderContext) => {
|
||||
|
@ -56,18 +82,9 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
{
|
||||
field: '@timestamp',
|
||||
},
|
||||
{
|
||||
field: ALERT_RULE_CONSUMER,
|
||||
},
|
||||
{
|
||||
field: ALERT_UUID,
|
||||
},
|
||||
{
|
||||
field: 'event.kind',
|
||||
},
|
||||
],
|
||||
factoryQueryType: TimelineEventsQueries.all,
|
||||
fieldRequested: ['@timestamp', 'message', ALERT_RULE_CONSUMER, ALERT_UUID, 'event.kind'],
|
||||
fieldRequested: ['@timestamp'],
|
||||
fields: [],
|
||||
filterQuery: {
|
||||
bool: {
|
||||
|
@ -98,6 +115,10 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
});
|
||||
|
||||
describe('Timeline - Events', () => {
|
||||
const logFilePath = Path.resolve(__dirname, '../../../common/audit.log');
|
||||
const logFile = new FileWrapper(logFilePath);
|
||||
const retry = getService('retry');
|
||||
|
||||
before(async () => {
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
|
||||
});
|
||||
|
@ -162,14 +183,15 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
});
|
||||
}
|
||||
|
||||
describe('alerts authentication', () => {
|
||||
// TODO - tests need to be updated with new table logic
|
||||
describe.skip('alerts authentication', () => {
|
||||
addTests({
|
||||
space: SPACE_1,
|
||||
featureIds: ['apm'],
|
||||
expectedNumberAlerts: 2,
|
||||
body: {
|
||||
...getPostBody(),
|
||||
defaultIndex: ['.alerts-*'],
|
||||
defaultIndex: ['.alerts*'],
|
||||
entityType: 'alerts',
|
||||
alertConsumers: ['apm'],
|
||||
},
|
||||
|
@ -177,5 +199,80 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
unauthorizedUsers: [obsMinRead, obsMinReadSpacesAll],
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
beforeEach(async () => {
|
||||
await logFile.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await logFile.reset();
|
||||
});
|
||||
|
||||
it('logs success events when reading alerts', async () => {
|
||||
await supertestWithoutAuth
|
||||
.post(`${getSpaceUrlPrefix(SPACE_1)}${TEST_URL}`)
|
||||
.auth(superUser.username, superUser.password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
...getPostBody(),
|
||||
defaultIndex: ['.alerts-*'],
|
||||
entityType: 'alerts',
|
||||
alertConsumers: ['apm'],
|
||||
})
|
||||
.expect(200);
|
||||
await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty());
|
||||
|
||||
const content = await logFile.readJSON();
|
||||
|
||||
const httpEvent = content.find((c) => c.event.action === 'http_request');
|
||||
expect(httpEvent).to.be.ok();
|
||||
expect(httpEvent.trace.id).to.be.ok();
|
||||
expect(httpEvent.user.name).to.be(superUser.username);
|
||||
expect(httpEvent.kibana.space_id).to.be('space1');
|
||||
expect(httpEvent.http.request.method).to.be('post');
|
||||
expect(httpEvent.url.path).to.be('/s/space1/internal/search/timelineSearchStrategy/');
|
||||
|
||||
const findEvents = content.filter((c) => c.event.action === 'alert_find');
|
||||
expect(findEvents[0].trace.id).to.be.ok();
|
||||
expect(findEvents[0].event.outcome).to.be('success');
|
||||
expect(findEvents[0].user.name).to.be(superUser.username);
|
||||
expect(findEvents[0].kibana.space_id).to.be('space1');
|
||||
});
|
||||
|
||||
it('logs failure events when unauthorized to read alerts', async () => {
|
||||
await supertestWithoutAuth
|
||||
.post(`${getSpaceUrlPrefix(SPACE_2)}${TEST_URL}`)
|
||||
.auth(obsMinRead.username, obsMinRead.password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
...getPostBody(),
|
||||
defaultIndex: ['.alerts-*'],
|
||||
entityType: 'alerts',
|
||||
alertConsumers: ['apm'],
|
||||
})
|
||||
.expect(500);
|
||||
await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty());
|
||||
|
||||
const content = await logFile.readJSON();
|
||||
|
||||
const httpEvent = content.find((c) => c.event.action === 'http_request');
|
||||
expect(httpEvent).to.be.ok();
|
||||
expect(httpEvent.trace.id).to.be.ok();
|
||||
expect(httpEvent.user.name).to.be(obsMinRead.username);
|
||||
expect(httpEvent.kibana.space_id).to.be(SPACE_2);
|
||||
expect(httpEvent.http.request.method).to.be('post');
|
||||
expect(httpEvent.url.path).to.be('/s/space2/internal/search/timelineSearchStrategy/');
|
||||
|
||||
const findEvents = content.filter((c) => c.event.action === 'alert_find');
|
||||
expect(findEvents.length).to.equal(1);
|
||||
expect(findEvents[0].trace.id).to.be.ok();
|
||||
expect(findEvents[0].event.outcome).to.be('failure');
|
||||
expect(findEvents[0].user.name).to.be(obsMinRead.username);
|
||||
expect(findEvents[0].kibana.space_id).to.be(SPACE_2);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue