Skip to content

Commit

Permalink
[Security Solution] enable cert check for usage-api calls (elastic#19…
Browse files Browse the repository at this point in the history
…4133)

## Summary

Enables cert validation for usage-api requests if configs are provided.
Also updated to use the usage-api url provided by configs. Maintains
existing functionality if no configs are provided which is to be removed
in a separate PR once configs are fully propagated.


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios


### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
  • Loading branch information
joeypoon authored Oct 11, 2024
1 parent edd8f08 commit b30c19f
Show file tree
Hide file tree
Showing 11 changed files with 300 additions and 60 deletions.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* 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 fetch from 'node-fetch';
import https from 'https';
import { merge } from 'lodash';

import { KBN_CERT_PATH, KBN_KEY_PATH, CA_CERT_PATH } from '@kbn/dev-utils';

import type { UsageApiConfigSchema } from '../../config';
import type { UsageRecord } from '../../types';

import { UsageReportingService } from './usage_reporting_service';
import { USAGE_REPORTING_ENDPOINT, USAGE_SERVICE_USAGE_URL } from '../../constants';

jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch');

describe('UsageReportingService', () => {
let usageApiConfig: UsageApiConfigSchema;
let service: UsageReportingService;

function generateUsageApiConfig(overrides?: Partial<UsageApiConfigSchema>): UsageApiConfigSchema {
const DEFAULT_USAGE_API_CONFIG = { enabled: false };
usageApiConfig = merge(DEFAULT_USAGE_API_CONFIG, overrides);

return usageApiConfig;
}

function setupService(
usageApi: UsageApiConfigSchema = generateUsageApiConfig()
): UsageReportingService {
service = new UsageReportingService(usageApi);
return service;
}

function generateUsageRecord(overrides?: Partial<UsageRecord>): UsageRecord {
const date = new Date().toISOString();
const DEFAULT_USAGE_RECORD = {
id: `usage-record-id-${date}`,
usage_timestamp: date,
creation_timestamp: date,
usage: {},
source: {},
} as UsageRecord;
return merge(DEFAULT_USAGE_RECORD, overrides);
}

afterEach(() => {
jest.clearAllMocks();
});

describe('usageApi configs not provided', () => {
beforeEach(() => {
setupService();
});

it('should still work if usageApi.url is not provided', async () => {
const usageRecord = generateUsageRecord();
const records: UsageRecord[] = [usageRecord];
const mockResponse = new Response(null, { status: 200 });
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce(mockResponse);

const response = await service.reportUsage(records);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(USAGE_SERVICE_USAGE_URL, {
method: 'post',
body: JSON.stringify(records),
headers: { 'Content-Type': 'application/json' },
agent: expect.any(https.Agent),
});
expect(response).toBe(mockResponse);
});

it('should use an agent with rejectUnauthorized false if config.enabled is false', async () => {
const usageRecord = generateUsageRecord();
const records: UsageRecord[] = [usageRecord];
const mockResponse = new Response(null, { status: 200 });
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce(mockResponse);

const response = await service.reportUsage(records);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(USAGE_SERVICE_USAGE_URL, {
method: 'post',
body: JSON.stringify(records),
headers: { 'Content-Type': 'application/json' },
agent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: false }),
}),
});
expect(response).toBe(mockResponse);
});

it('should not set agent if the URL is not https', async () => {
const url = 'http://usage-api.example';
setupService(generateUsageApiConfig({ url }));
const usageRecord = generateUsageRecord();
const records: UsageRecord[] = [usageRecord];
const mockResponse = new Response(null, { status: 200 });
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(mockResponse);

const response = await service.reportUsage(records);

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(`${url}${USAGE_REPORTING_ENDPOINT}`, {
method: 'post',
body: JSON.stringify(records),
headers: { 'Content-Type': 'application/json' },
});
expect(response).toBe(mockResponse);
});
});

describe('usageApi configs provided', () => {
const DEFAULT_CONFIG = {
enabled: true,
url: 'https://usage-api.example',
tls: {
certificate: KBN_CERT_PATH,
key: KBN_KEY_PATH,
ca: CA_CERT_PATH,
},
};

beforeEach(() => {
setupService(generateUsageApiConfig(DEFAULT_CONFIG));
});

it('should use usageApi.url if provided', async () => {
const usageRecord = generateUsageRecord();
const records: UsageRecord[] = [usageRecord];
const mockResponse = new Response(null, { status: 200 });
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce(mockResponse);

const response = await service.reportUsage(records);
const url = `${DEFAULT_CONFIG.url}${USAGE_REPORTING_ENDPOINT}`;

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(url, {
method: 'post',
body: JSON.stringify(records),
headers: { 'Content-Type': 'application/json' },
agent: expect.any(https.Agent),
});
expect(response).toBe(mockResponse);
});

it('should use an agent with TLS configuration if config.enabled is true', async () => {
const usageRecord = generateUsageRecord();
const records: UsageRecord[] = [usageRecord];
const mockResponse = new Response(null, { status: 200 });
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce(mockResponse);

const response = await service.reportUsage(records);
const url = `${DEFAULT_CONFIG.url}${USAGE_REPORTING_ENDPOINT}`;

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(url, {
method: 'post',
body: JSON.stringify(records),
headers: { 'Content-Type': 'application/json' },
agent: expect.objectContaining({
options: expect.objectContaining({
cert: expect.any(String),
key: expect.any(String),
ca: expect.arrayContaining([expect.any(String)]),
}),
}),
});
expect(response).toBe(mockResponse);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,77 @@
* 2.0.
*/

import type { Response } from 'node-fetch';
import type { RequestInit, Response } from 'node-fetch';

import fetch from 'node-fetch';
import https from 'https';

import { USAGE_SERVICE_USAGE_URL } from '../../constants';
import { SslConfig, sslSchema } from '@kbn/server-http-tools';

import type { UsageRecord } from '../../types';
import type { UsageApiConfigSchema, TlsConfigSchema } from '../../config';

import { USAGE_REPORTING_ENDPOINT, USAGE_SERVICE_USAGE_URL } from '../../constants';

// TODO remove once we have the CA available
const agent = new https.Agent({ rejectUnauthorized: false });
export class UsageReportingService {
public async reportUsage(
records: UsageRecord[],
url = USAGE_SERVICE_USAGE_URL
): Promise<Response> {
const isHttps = url.includes('https');
private agent: https.Agent | undefined;

return fetch(url, {
constructor(private readonly config: UsageApiConfigSchema) {}

public async reportUsage(records: UsageRecord[]): Promise<Response> {
const reqArgs: RequestInit = {
method: 'post',
body: JSON.stringify(records),
headers: { 'Content-Type': 'application/json' },
agent: isHttps ? agent : undefined, // Conditionally add agent if URL is HTTPS for supporting integration tests.
};
if (this.usageApiUrl.includes('https')) {
reqArgs.agent = this.httpAgent;
}
return fetch(this.usageApiUrl, reqArgs);
}

private get tlsConfigs(): NonNullable<TlsConfigSchema> {
if (!this.config.tls) {
throw new Error('UsageReportingService: usageApi.tls configs not provided');
}

return this.config.tls;
}

private get usageApiUrl(): string {
if (!this.config.url) {
return USAGE_SERVICE_USAGE_URL;
}

return `${this.config.url}${USAGE_REPORTING_ENDPOINT}`;
}

private get httpAgent(): https.Agent {
if (this.agent) {
return this.agent;
}

if (!this.config.enabled) {
this.agent = new https.Agent({ rejectUnauthorized: false });
return this.agent;
}

const tlsConfig = new SslConfig(
sslSchema.validate({
enabled: true,
certificate: this.tlsConfigs.certificate,
key: this.tlsConfigs.key,
certificateAuthorities: this.tlsConfigs.ca,
})
);

this.agent = new https.Agent({
rejectUnauthorized: tlsConfig.rejectUnauthorized,
cert: tlsConfig.certificate,
key: tlsConfig.key,
ca: tlsConfig.certificateAuthorities,
});

return this.agent;
}
}

export const usageReportingService = new UsageReportingService();
26 changes: 13 additions & 13 deletions x-pack/plugins/security_solution_serverless/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@ import type { ExperimentalFeatures } from '../common/experimental_features';
import { productTypes } from '../common/config';
import { parseExperimentalConfigValue } from '../common/experimental_features';

const usageApiConfig = schema.maybe(
schema.object({
enabled: schema.maybe(schema.boolean()),
url: schema.string(),
tls: schema.maybe(
schema.object({
certificate: schema.string(),
key: schema.string(),
ca: schema.string(),
})
),
})
);
const tlsConfig = schema.object({
certificate: schema.string(),
key: schema.string(),
ca: schema.string(),
});
export type TlsConfigSchema = TypeOf<typeof tlsConfig>;

const usageApiConfig = schema.object({
enabled: schema.boolean({ defaultValue: false }),
url: schema.maybe(schema.string()),
tls: schema.maybe(tlsConfig),
});
export type UsageApiConfigSchema = TypeOf<typeof usageApiConfig>;

export const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ const namespace = 'elastic-system';
const USAGE_SERVICE_BASE_API_URL = `https://usage-api.${namespace}/api`;
const USAGE_SERVICE_BASE_API_URL_V1 = `${USAGE_SERVICE_BASE_API_URL}/v1`;
export const USAGE_SERVICE_USAGE_URL = `${USAGE_SERVICE_BASE_API_URL_V1}/usage`;
export const USAGE_REPORTING_ENDPOINT = '/api/v1/usage';
export const METERING_SERVICE_BATCH_SIZE = 1000;
6 changes: 6 additions & 0 deletions x-pack/plugins/security_solution_serverless/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from './endpoint/services';
import { NLPCleanupTask } from './task_manager/nlp_cleanup_task/nlp_cleanup_task';
import { telemetryEvents } from './telemetry/event_based_telemetry';
import { UsageReportingService } from './common/services/usage_reporting_service';

export class SecuritySolutionServerlessPlugin
implements
Expand All @@ -49,11 +50,14 @@ export class SecuritySolutionServerlessPlugin
private endpointUsageReportingTask: SecurityUsageReportingTask | undefined;
private nlpCleanupTask: NLPCleanupTask | undefined;
private readonly logger: Logger;
private readonly usageReportingService: UsageReportingService;

constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ServerlessSecurityConfig>();
this.logger = this.initializerContext.logger.get();

this.usageReportingService = new UsageReportingService(this.config.usageApi);

const productTypesStr = JSON.stringify(this.config.productTypes, null, 2);
this.logger.info(`Security Solution running with product types:\n${productTypesStr}`);
}
Expand Down Expand Up @@ -83,6 +87,7 @@ export class SecuritySolutionServerlessPlugin
taskTitle: cloudSecurityMetringTaskProperties.taskTitle,
version: cloudSecurityMetringTaskProperties.version,
meteringCallback: cloudSecurityMetringTaskProperties.meteringCallback,
usageReportingService: this.usageReportingService,
});

this.endpointUsageReportingTask = new SecurityUsageReportingTask({
Expand All @@ -95,6 +100,7 @@ export class SecuritySolutionServerlessPlugin
meteringCallback: endpointMeteringService.getUsageRecords,
taskManager: pluginsSetup.taskManager,
cloudSetup: pluginsSetup.cloud,
usageReportingService: this.usageReportingService,
});

this.nlpCleanupTask = new NLPCleanupTask({
Expand Down
Loading

0 comments on commit b30c19f

Please sign in to comment.