diff --git a/package.json b/package.json index 73ae2cc388543..68c9b1e6bec4a 100644 --- a/package.json +++ b/package.json @@ -866,6 +866,7 @@ "ansi-regex": "^5.0.1", "antlr4ts": "^0.5.0-alpha.3", "archiver": "^5.3.1", + "argon2": "0.31.1", "async": "^3.2.3", "aws4": "^1.12.0", "axios": "^1.6.0", diff --git a/x-pack/plugins/fleet/common/types/models/output.ts b/x-pack/plugins/fleet/common/types/models/output.ts index 3283f4d01e540..5f6a6db3315e9 100644 --- a/x-pack/plugins/fleet/common/types/models/output.ts +++ b/x-pack/plugins/fleet/common/types/models/output.ts @@ -23,7 +23,12 @@ export type KafkaPartitionType = typeof kafkaPartitionType; export type KafkaTopicWhenType = typeof kafkaTopicWhenType; export type KafkaAcknowledgeReliabilityLevel = typeof kafkaAcknowledgeReliabilityLevel; export type KafkaVerificationMode = typeof kafkaVerificationModes; - +export type OutputSecret = + | string + | { + id: string; + hash?: string; + }; interface NewBaseOutput { is_default: boolean; is_default_monitoring: boolean; @@ -45,11 +50,7 @@ interface NewBaseOutput { allow_edit?: string[]; secrets?: { ssl?: { - key?: - | string - | { - id: string; - }; + key?: OutputSecret; }; }; } @@ -131,17 +132,9 @@ export interface KafkaOutput extends NewBaseOutput { broker_timeout?: number; required_acks?: ValueOf; secrets?: { - password?: - | string - | { - id: string; - }; + password?: OutputSecret; ssl?: { - key?: - | string - | { - id: string; - }; + key?: OutputSecret; }; }; } diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 0cb1099b58ebb..016026533e3c7 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -419,7 +419,12 @@ class OutputService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, output: NewOutput, - options?: { id?: string; fromPreconfiguration?: boolean; overwrite?: boolean } + options?: { + id?: string; + fromPreconfiguration?: boolean; + overwrite?: boolean; + secretHashes?: Record; + } ): Promise { const data: OutputSOAttributes = { ...omit(output, ['ssl', 'secrets']) }; if (output.type === outputType.RemoteElasticsearch) { @@ -555,6 +560,7 @@ class OutputService { const { output: outputWithSecrets } = await extractAndWriteOutputSecrets({ output, esClient, + secretHashes: output.is_preconfigured ? options?.secretHashes : undefined, }); if (outputWithSecrets.secrets) data.secrets = outputWithSecrets.secrets; @@ -716,7 +722,10 @@ class OutputService { esClient: ElasticsearchClient, id: string, data: Partial, - { fromPreconfiguration = false }: { fromPreconfiguration: boolean } = { + { + fromPreconfiguration = false, + secretHashes, + }: { fromPreconfiguration: boolean; secretHashes?: Record } = { fromPreconfiguration: false, } ) { @@ -747,6 +756,7 @@ class OutputService { oldOutput: originalOutput, outputUpdate: data, esClient, + secretHashes: data.is_preconfigured ? secretHashes : undefined, }); updateData.secrets = secretsRes.outputUpdate.secrets; diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/outputs.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.test.ts index 1023e1bdb7b56..a0dd3fd5e7f5d 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration/outputs.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.test.ts @@ -17,6 +17,7 @@ import { createOrUpdatePreconfiguredOutputs, cleanPreconfiguredOutputs, getPreconfiguredOutputFromConfig, + hash, } from './outputs'; jest.mock('../agent_policy_update'); @@ -46,16 +47,18 @@ const spyAgentPolicyServicBumpAllAgentPoliciesForOutput = jest.spyOn( ); describe('output preconfiguration', () => { - beforeEach(() => { + beforeEach(async () => { mockedOutputService.create.mockReset(); mockedOutputService.update.mockReset(); mockedOutputService.delete.mockReset(); mockedOutputService.getDefaultDataOutputId.mockReset(); mockedOutputService.getDefaultESHosts.mockReturnValue(['http://default-es:9200']); + const keyHash = await hash('secretKey'); + const passwordHash = await hash('secretPassword'); mockedOutputService.bulkGet.mockImplementation(async (soClient, id): Promise => { return [ { - id: 'existing-output-1', + id: 'existing-es-output-1', is_default: false, is_default_monitoring: false, name: 'Output 1', @@ -74,8 +77,76 @@ describe('output preconfiguration', () => { hosts: ['kafka.co:80'], is_preconfigured: true, }, + { + id: 'existing-logstash-output-with-secrets-1', + is_default: false, + is_default_monitoring: false, + name: 'Logstash Output With Secrets 1', + type: 'logstash', + hosts: ['test:4343'], + is_preconfigured: true, + secrets: { + ssl: { + key: { + id: '123', + hash: keyHash, + }, + }, + }, + }, + { + id: 'existing-logstash-output-with-secrets-2', + is_default: false, + is_default_monitoring: false, + name: 'Logstash Output With Secrets 2', + type: 'logstash', + hosts: ['test:4343'], + is_preconfigured: true, + secrets: { + ssl: { + key: 'secretKey', + }, + }, + }, + { + id: 'existing-kafka-output-with-secrets-1', + is_default: false, + is_default_monitoring: false, + name: 'Kafka Output With Secrets 1', + type: 'kafka', + hosts: ['kafka.co:80'], + is_preconfigured: true, + secrets: { + password: { + id: '456', + hash: passwordHash, + }, + ssl: { + key: { + id: '789', + hash: keyHash, + }, + }, + }, + }, + { + id: 'existing-kafka-output-with-secrets-2', + is_default: false, + is_default_monitoring: false, + name: 'Kafka Output With Secrets 2', + type: 'kafka', + hosts: ['kafka.co:80'], + is_preconfigured: true, + secrets: { + password: 'secretPassword', + ssl: { + key: 'secretKey', + }, + }, + }, ]; }); + spyAgentPolicyServicBumpAllAgentPoliciesForOutput.mockClear(); }); it('should generate a preconfigured output if elasticsearch.hosts is set in the config', async () => { @@ -104,7 +175,7 @@ describe('output preconfiguration', () => { `); }); - it('should create preconfigured output that does not exists', async () => { + it('should create preconfigured output that does not exist', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ @@ -123,7 +194,7 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); }); - it('should create preconfigured kafka output that does not exists', async () => { + it('should create preconfigured kafka output that does not exist', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ @@ -142,7 +213,7 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); }); - it('should create a preconfigured output with ca_trusted_fingerprint that does not exists', async () => { + it('should create a preconfigured output with ca_trusted_fingerprint that does not exist', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ @@ -170,7 +241,7 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); }); - it('should create preconfigured logstash output that does not exist', async () => { + it('should create a preconfigured logstash output that does not exist', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ @@ -190,7 +261,66 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); }); - it('should set default hosts if hosts is not set output that does not exists', async () => { + it('should create a preconfigured logstash output with secrets that does not exist', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'non-existing-logstash-output-with-secrets-1', + name: 'Logstash Output With Secrets 2', + type: 'logstash', + is_default: false, + is_default_monitoring: false, + secrets: { + ssl: { + key: 'secretKey', + }, + }, + }, + ]); + + expect(mockedOutputService.create).toBeCalled(); + expect(mockedOutputService.create).toBeCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + secrets: { + ssl: { + key: 'secretKey', + }, + }, + }), + expect.anything() + ); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + + it('should create a preconfigured kafka output with secrets that does not exist', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'non-existing-kafka-output-with-secrets-1', + name: 'Kafka Output With Secrets 2', + type: 'kafka', + is_default: false, + is_default_monitoring: false, + secrets: { + password: 'secretPassword', + ssl: { + key: 'secretKey', + }, + }, + }, + ]); + + expect(mockedOutputService.create).toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + + it('should set default hosts if hosts is not set output that does not exist', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ @@ -213,7 +343,7 @@ describe('output preconfiguration', () => { soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); mockedOutputService.bulkGet.mockResolvedValue([ { - id: 'existing-output-1', + id: 'existing-es-output-1', is_default: false, is_default_monitoring: false, name: 'Output 1', @@ -225,7 +355,7 @@ describe('output preconfiguration', () => { ]); await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ { - id: 'existing-output-1', + id: 'existing-es-output-1', is_default: false, is_default_monitoring: false, name: 'Output 1', @@ -239,7 +369,7 @@ describe('output preconfiguration', () => { expect(mockedOutputService.update).toBeCalledWith( expect.anything(), expect.anything(), - 'existing-output-1', + 'existing-es-output-1', expect.objectContaining({ is_preconfigured: true, }), @@ -254,7 +384,7 @@ describe('output preconfiguration', () => { soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ { - id: 'existing-output-1', + id: 'existing-es-output-1', is_default: false, is_default_monitoring: false, name: 'Output 1', @@ -268,6 +398,30 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); }); + it('should update output if a preconfigured logstash ouput with secrets exists and has changed', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-logstash-output-with-secrets-1', + is_default: false, + is_default_monitoring: false, + name: 'Logstash Output With Secrets 1', + type: 'logstash', + secrets: { + ssl: { + key: 'secretKey2', // field that changed + }, + }, + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); + }); + it('should update output if preconfigured kafka output exists and changed', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -279,7 +433,7 @@ describe('output preconfiguration', () => { is_default_monitoring: false, name: 'Kafka Output 1', type: 'kafka', - hosts: ['kafka.co:8080'], + hosts: ['kafka.co:8080'], // field that changed }, ]); @@ -288,18 +442,23 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); }); - it('should not update output if preconfigured output exists and did not changed', async () => { + it('should update ouput if a preconfigured kafka with secrets exists and has changed', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ { - id: 'existing-output-1', + id: 'existing-kafka-output-with-secrets-1', is_default: false, is_default_monitoring: false, - name: 'Output 1', - type: 'elasticsearch', - hosts: ['http://newhostichanged.co:9201'], // field that changed + name: 'Kafka Output With Secrets 1', + type: 'kafka', + secrets: { + password: 'secretPassword2', // field that changed + ssl: { + key: 'secretKey2', + }, + }, }, ]); @@ -308,6 +467,26 @@ describe('output preconfiguration', () => { expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); }); + it('should not update output if preconfigured output exists and did not change', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-es-output-1', + is_default: false, + is_default_monitoring: false, + name: 'Output 1', + type: 'elasticsearch', + hosts: ['http://es.co:80'], + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + it('should not update output if preconfigured kafka output exists and did not change', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -319,7 +498,109 @@ describe('output preconfiguration', () => { is_default_monitoring: false, name: 'Kafka Output 1', type: 'kafka', - hosts: ['kafka.co:8080'], + hosts: ['kafka.co:80'], + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + + it('should not update output if a preconfigured logstash output with secrets exists and did not change', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-logstash-output-with-secrets-1', + is_default: false, + is_default_monitoring: false, + name: 'Logstash Output With Secrets 1', + type: 'logstash', + hosts: ['test:4343'], + secrets: { + ssl: { + key: 'secretKey', + }, + }, + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + + it('should not update output if a preconfigured kafka output with secrets exists and did not change', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-kafka-output-with-secrets-1', + is_default: false, + is_default_monitoring: false, + name: 'Kafka Output With Secrets 1', + type: 'kafka', + hosts: ['kafka.co:80'], + secrets: { + password: 'secretPassword', + ssl: { + key: 'secretKey', + }, + }, + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).not.toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled(); + }); + + it('should update output if a preconfigured logstash output with plain value secrets exists and did not change', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-logstash-output-with-secrets-2', + is_default: false, + is_default_monitoring: false, + name: 'Logstash Output With Secrets 2', + type: 'logstash', + hosts: ['test:4343'], + secrets: { + ssl: { + key: 'secretKey', // no change + }, + }, + }, + ]); + + expect(mockedOutputService.create).not.toBeCalled(); + expect(mockedOutputService.update).toBeCalled(); + expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled(); + }); + + it('should update output if a preconfigured kafka output with plain value secrets exists and did not change', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 }); + await createOrUpdatePreconfiguredOutputs(soClient, esClient, [ + { + id: 'existing-kafka-output-with-secrets-2', + is_default: false, + is_default_monitoring: false, + name: 'Kafka Output With Secrets 2', + type: 'kafka', + hosts: ['kafka.co:80'], + secrets: { + password: 'secretPassword', // no change + ssl: { + key: 'secretKey', // no change + }, + }, }, ]); @@ -332,7 +613,7 @@ describe('output preconfiguration', () => { { name: 'no changes', data: { - id: 'existing-output-1', + id: 'existing-es-output-1', is_default: false, is_default_monitoring: false, name: 'Output 1', @@ -343,7 +624,7 @@ describe('output preconfiguration', () => { { name: 'hosts without port', data: { - id: 'existing-output-1', + id: 'existing-es-output-1', is_default: false, is_default_monitoring: false, name: 'Output 1', diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts index 5bc7c452b481e..07636dd1266c0 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration/outputs.ts @@ -8,8 +8,16 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import { isEqual } from 'lodash'; import { safeDump } from 'js-yaml'; +import argon2 from 'argon2'; -import type { PreconfiguredOutput, Output, NewOutput } from '../../../common/types'; +import type { + PreconfiguredOutput, + Output, + NewOutput, + OutputSecret, + KafkaOutput, + NewLogstashOutput, +} from '../../../common/types'; import { normalizeHostsForAgents } from '../../../common/services'; import type { FleetConfigType } from '../../config'; import { DEFAULT_OUTPUT_ID, DEFAULT_OUTPUT } from '../../constants'; @@ -99,25 +107,79 @@ export async function createOrUpdatePreconfiguredOutputs( } const isUpdateWithNewData = - existingOutput && isPreconfiguredOutputDifferentFromCurrent(existingOutput, data); - - if (isCreate) { - logger.debug(`Creating output ${output.id}`); - await outputService.create(soClient, esClient, data, { id, fromPreconfiguration: true }); - } else if (isUpdateWithNewData) { - logger.debug(`Updating output ${output.id}`); - await outputService.update(soClient, esClient, id, data, { fromPreconfiguration: true }); - // Bump revision of all policies using that output - if (outputData.is_default || outputData.is_default_monitoring) { - await agentPolicyService.bumpAllAgentPolicies(soClient, esClient); - } else { - await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, id); + existingOutput && (await isPreconfiguredOutputDifferentFromCurrent(existingOutput, data)); + + if (isCreate || isUpdateWithNewData) { + const secretHashes = await hashSecrets(output); + + if (isCreate) { + logger.debug(`Creating preconfigured output ${output.id}`); + await outputService.create(soClient, esClient, data, { + id, + fromPreconfiguration: true, + secretHashes, + }); + } else if (isUpdateWithNewData) { + logger.debug(`Updating preconfigured output ${output.id}`); + await outputService.update(soClient, esClient, id, data, { + fromPreconfiguration: true, + secretHashes, + }); + // Bump revision of all policies using that output + if (outputData.is_default || outputData.is_default_monitoring) { + await agentPolicyService.bumpAllAgentPolicies(soClient, esClient); + } else { + await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, id); + } } } }) ); } +export async function hash(str: string) { + return argon2.hash(str, { + type: argon2.argon2id, + memoryCost: 19456, + timeCost: 2, + parallelism: 1, + }); +} + +async function hashSecrets(output: PreconfiguredOutput) { + if (output.type === 'kafka') { + const kafkaOutput = output as KafkaOutput; + if (typeof kafkaOutput.secrets?.password === 'string') { + const password = await hash(kafkaOutput.secrets?.password); + return { + password, + }; + } + if (typeof kafkaOutput.secrets?.ssl?.key === 'string') { + const key = await hash(kafkaOutput.secrets?.ssl?.key); + return { + ssl: { + key, + }, + }; + } + } + if (output.type === 'logstash') { + const logstashOutput = output as NewLogstashOutput; + + if (typeof logstashOutput.secrets?.ssl?.key === 'string') { + const key = await hash(logstashOutput.secrets?.ssl?.key); + return { + ssl: { + key, + }, + }; + } + } + + return undefined; +} + export async function cleanPreconfiguredOutputs( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -165,15 +227,56 @@ export async function cleanPreconfiguredOutputs( } } -function isPreconfiguredOutputDifferentFromCurrent( +const hasHash = (secret?: OutputSecret): secret is { id: string; hash: string } => { + return !!secret && typeof secret !== 'string' && !!secret.hash; +}; + +async function isSecretDifferent( + preconfiguredValue: OutputSecret | undefined, + existingSecret: OutputSecret | undefined +): Promise { + if (!existingSecret && preconfiguredValue) { + return true; + } + + if (!preconfiguredValue && existingSecret) { + return true; + } + + if (!preconfiguredValue && !existingSecret) { + return false; + } + + if (hasHash(existingSecret) && typeof preconfiguredValue === 'string') { + // verifying the has tells us if the value has changed + const hashIsVerified = await argon2.verify(existingSecret.hash, preconfiguredValue!); + + return !hashIsVerified; + } else { + // if there is no hash then the safest thing to do is assume the value has changed + return true; + } +} + +async function isPreconfiguredOutputDifferentFromCurrent( existingOutput: Output, preconfiguredOutput: Partial -): boolean { - const kafkaFieldsAreDifferent = (): boolean => { +): Promise { + const kafkaFieldsAreDifferent = async (): Promise => { if (existingOutput.type !== 'kafka' || preconfiguredOutput.type !== 'kafka') { return false; } + const passwordHashIsDifferent = await isSecretDifferent( + preconfiguredOutput.secrets?.password, + existingOutput.secrets?.password + ); + + const sslKeyHashIsDifferent = await isSecretDifferent( + preconfiguredOutput.secrets?.ssl?.key, + existingOutput.secrets?.ssl?.key + ); + return ( isDifferent(existingOutput.client_id, preconfiguredOutput.client_id) || isDifferent(existingOutput.version, preconfiguredOutput.version) || @@ -193,10 +296,24 @@ function isPreconfiguredOutputDifferentFromCurrent( isDifferent(existingOutput.headers, preconfiguredOutput.headers) || isDifferent(existingOutput.timeout, preconfiguredOutput.timeout) || isDifferent(existingOutput.broker_timeout, preconfiguredOutput.broker_timeout) || - isDifferent(existingOutput.required_acks, preconfiguredOutput.required_acks) + isDifferent(existingOutput.required_acks, preconfiguredOutput.required_acks) || + passwordHashIsDifferent || + sslKeyHashIsDifferent ); }; + const logstashFieldsAreDifferent = async (): Promise => { + if (existingOutput.type !== 'logstash' || preconfiguredOutput.type !== 'logstash') { + return false; + } + const sslKeyHashIsDifferent = await isSecretDifferent( + preconfiguredOutput.secrets?.ssl?.key, + existingOutput.secrets?.ssl?.key + ); + + return sslKeyHashIsDifferent; + }; + return ( !existingOutput.is_preconfigured || isDifferent(existingOutput.is_default, preconfiguredOutput.is_default) || @@ -221,6 +338,7 @@ function isPreconfiguredOutputDifferentFromCurrent( isDifferent(existingOutput.config_yaml, preconfiguredOutput.config_yaml) || isDifferent(existingOutput.proxy_id, preconfiguredOutput.proxy_id) || isDifferent(existingOutput.allow_edit ?? [], preconfiguredOutput.allow_edit ?? []) || - kafkaFieldsAreDifferent() + (await kafkaFieldsAreDifferent()) || + (await logstashFieldsAreDifferent()) ); } diff --git a/x-pack/plugins/fleet/server/services/secrets.ts b/x-pack/plugins/fleet/server/services/secrets.ts index 36a88b4a7a4c1..baabdf55e793d 100644 --- a/x-pack/plugins/fleet/server/services/secrets.ts +++ b/x-pack/plugins/fleet/server/services/secrets.ts @@ -7,7 +7,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; -import { keyBy } from 'lodash'; +import { get, keyBy } from 'lodash'; import { set } from '@kbn/safer-lodash-set'; import type { KafkaOutput, Output, OutputSecretPath } from '../../common/types'; @@ -247,8 +247,9 @@ export async function extractAndWriteSecrets(opts: { export async function extractAndWriteOutputSecrets(opts: { output: NewOutput; esClient: ElasticsearchClient; + secretHashes?: Record; }): Promise<{ output: NewOutput; secretReferences: PolicySecretReference[] }> { - const { output, esClient } = opts; + const { output, esClient, secretHashes = {} } = opts; const secretPaths = getOutputSecretPaths(output.type, output).filter( (path) => typeof path.value === 'string' @@ -265,7 +266,12 @@ export async function extractAndWriteOutputSecrets(opts: { const outputWithSecretRefs = JSON.parse(JSON.stringify(output)); secretPaths.forEach((secretPath, i) => { - set(outputWithSecretRefs, secretPath.path, { id: secrets[i].id }); + const pathWithoutPrefix = secretPath.path.replace('secrets.', ''); + const maybeHash = get(secretHashes, pathWithoutPrefix); + set(outputWithSecretRefs, secretPath.path, { + id: secrets[i].id, + ...(typeof maybeHash === 'string' && { hash: maybeHash }), + }); }); return { @@ -399,12 +405,13 @@ export async function extractAndUpdateOutputSecrets(opts: { oldOutput: Output; outputUpdate: Partial; esClient: ElasticsearchClient; + secretHashes?: Record; }): Promise<{ outputUpdate: Partial; secretReferences: PolicySecretReference[]; secretsToDelete: PolicySecretReference[]; }> { - const { oldOutput, outputUpdate, esClient } = opts; + const { oldOutput, outputUpdate, esClient, secretHashes } = opts; const outputType = outputUpdate.type || oldOutput.type; const oldSecretPaths = getOutputSecretPaths(outputType, oldOutput); const updatedSecretPaths = getOutputSecretPaths(outputType, outputUpdate); @@ -425,7 +432,13 @@ export async function extractAndUpdateOutputSecrets(opts: { const outputWithSecretRefs = JSON.parse(JSON.stringify(outputUpdate)); toCreate.forEach((secretPath, i) => { - set(outputWithSecretRefs, secretPath.path, { id: createdSecrets[i].id }); + const pathWithoutPrefix = secretPath.path.replace('secrets.', ''); + const maybeHash = get(secretHashes, pathWithoutPrefix); + + set(outputWithSecretRefs, secretPath.path, { + id: createdSecrets[i].id, + ...(typeof maybeHash === 'string' && { hash: maybeHash }), + }); }); const secretReferences = [ diff --git a/yarn.lock b/yarn.lock index a3a29ac19c070..aa126a61c9074 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6477,6 +6477,21 @@ resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz#c15367178d8bfe4765e6b47b542fe821ce259c7b" integrity sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ== +"@mapbox/node-pre-gyp@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + "@mapbox/point-geometry@0.1.0", "@mapbox/point-geometry@^0.1.0", "@mapbox/point-geometry@~0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2" @@ -7125,6 +7140,11 @@ node-addon-api "^3.2.1" node-gyp-build "^4.3.0" +"@phc/format@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@phc/format/-/format-1.0.0.tgz#b5627003b3216dc4362125b13f48a4daa76680e4" + integrity sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -11149,6 +11169,15 @@ arg@^5.0.1, arg@^5.0.2: resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== +argon2@0.31.1: + version "0.31.1" + resolved "https://registry.yarnpkg.com/argon2/-/argon2-0.31.1.tgz#c8560bc76b12681afea13e28f3417aaa4b84c466" + integrity sha512-ik2xnJrLXazya7m4Nz1XfBSRjXj8Koq8qF9PsQC8059p20ifWc9zx/hgU3ItZh/3TnwXkv0RbhvjodPkmFf0bg== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.11" + "@phc/format" "^1.0.0" + node-addon-api "^7.0.0" + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -22767,6 +22796,11 @@ node-addon-api@^6.1.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== +node-addon-api@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.0.0.tgz#8136add2f510997b3b94814f4af1cce0b0e3962e" + integrity sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA== + node-cache@^5.1.0: version "5.1.2" resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d"