From 3a5b31f7df8854b060376c50213239ec3d3a429d Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Fri, 23 Jan 2026 14:24:40 -0500 Subject: [PATCH 1/3] fix overlap of product switcher in side nav (#18533) --- libs/components/src/navigation/side-nav.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index 6b53c525e3a..b70d650622a 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -27,7 +27,7 @@
@if (data.open) { From a59760f83b112cfc77e5a2bf550c25eff20a6a83 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:30:31 +0100 Subject: [PATCH 2/3] [PM-26049] Always store users auto unlock key on Cli (#18477) * Always store auto user key on CLI * update unit tests * prevent bad vault timeout state * Update libs/key-management/src/key.service.ts Co-authored-by: Bernd Schoolmann --------- Co-authored-by: Bernd Schoolmann --- .../vault-timeout-settings.service.spec.ts | 170 ++++++++++++++++-- .../vault-timeout-settings.service.ts | 29 +-- libs/key-management/src/key.service.spec.ts | 14 +- libs/key-management/src/key.service.ts | 16 +- 4 files changed, 197 insertions(+), 32 deletions(-) diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts index ccb66a4dff4..3c391344f04 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts @@ -260,6 +260,13 @@ describe("VaultTimeoutSettingsService", () => { }); describe("getVaultTimeoutByUserId$", () => { + beforeEach(() => { + // Return the input value unchanged + sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation( + async (timeout) => timeout, + ); + }); + it("should throw an error if no user id is provided", async () => { expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow( "User id required. Cannot get vault timeout.", @@ -277,6 +284,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + defaultVaultTimeout, + ); expect(result).toBe(defaultVaultTimeout); }); @@ -299,8 +309,31 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + vaultTimeout, + ); expect(result).toBe(vaultTimeout); }); + + it("promotes timeout when unavailable on client", async () => { + const determinedTimeout = VaultTimeoutNumberType.OnMinute; + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue(of([])); + + await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + determinedTimeout, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: custom", () => { @@ -327,6 +360,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + policyMinutes, + ); expect(result).toBe(policyMinutes); }, ); @@ -345,6 +381,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + vaultTimeout, + ); expect(result).toBe(vaultTimeout); }, ); @@ -365,8 +404,36 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); expect(result).toBe(VaultTimeoutNumberType.Immediately); }); + + it("promotes policy minutes when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.Never; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutNumberType.EightHours, + mockUserId, + ); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + policyMinutes, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: immediately", () => { @@ -383,7 +450,6 @@ describe("VaultTimeoutSettingsService", () => { "when current timeout is %s, returns immediately or promoted value", async (currentTimeout) => { const expectedTimeout = VaultTimeoutNumberType.Immediately; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); policyService.policiesByType$.mockReturnValue( of([{ data: { type: "immediately" } }] as unknown as Policy[]), ); @@ -400,6 +466,26 @@ describe("VaultTimeoutSettingsService", () => { expect(result).toBe(expectedTimeout); }, ); + + it("promotes immediately when unavailable on client", async () => { + const promotedValue = VaultTimeoutNumberType.OnMinute; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "immediately" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: onSystemLock", () => { @@ -413,7 +499,6 @@ describe("VaultTimeoutSettingsService", () => { "when current timeout is %s, returns onLocked or promoted value", async (currentTimeout) => { const expectedTimeout = VaultTimeoutStringType.OnLocked; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); policyService.policiesByType$.mockReturnValue( of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), ); @@ -446,9 +531,31 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); + + it("promotes onLocked when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnLocked, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: onAppRestart", () => { @@ -468,7 +575,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnRestart, + ); expect(result).toBe(VaultTimeoutStringType.OnRestart); }); @@ -488,32 +597,40 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); - }); - describe("policy type: never", () => { - it("when current timeout is never, returns never or promoted value", async () => { - const expectedTimeout = VaultTimeoutStringType.Never; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); + it("promotes onRestart when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.Never; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); policyService.policiesByType$.mockReturnValue( - of([{ data: { type: "never" } }] as unknown as Policy[]), + of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]), ); - await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutStringType.OnLocked, + mockUserId, + ); const result = await firstValueFrom( vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( - VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, ); - expect(result).toBe(expectedTimeout); + expect(result).toBe(promotedValue); }); + }); + describe("policy type: never", () => { it.each([ + VaultTimeoutStringType.Never, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnIdle, @@ -532,9 +649,32 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); + + it("promotes timeout when unavailable on client", async () => { + const determinedTimeout = VaultTimeoutStringType.Never; + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "never" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + determinedTimeout, + ); + expect(result).toBe(promotedValue); + }); }); }); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index b8bc859d11c..57e484fd767 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -179,7 +179,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private async determineVaultTimeout( currentVaultTimeout: VaultTimeout | null, maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, - ): Promise { + ): Promise { + const determinedTimeout = await this.determineVaultTimeoutInternal( + currentVaultTimeout, + maxSessionTimeoutPolicyData, + ); + + // Ensures the timeout is available on this client + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout); + } + + private async determineVaultTimeoutInternal( + currentVaultTimeout: VaultTimeout | null, + maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, + ): Promise { // if current vault timeout is null, apply the client specific default currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout; @@ -190,9 +203,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA switch (maxSessionTimeoutPolicyData.type) { case "immediately": - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutNumberType.Immediately, - ); + return VaultTimeoutNumberType.Immediately; case "custom": case null: case undefined: @@ -211,9 +222,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA currentVaultTimeout === VaultTimeoutStringType.OnIdle || currentVaultTimeout === VaultTimeoutStringType.OnSleep ) { - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutStringType.OnLocked, - ); + return VaultTimeoutStringType.OnLocked; } break; case "onAppRestart": @@ -227,11 +236,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA } break; case "never": - if (currentVaultTimeout === VaultTimeoutStringType.Never) { - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutStringType.Never, - ); - } + // Policy doesn't override user preference for "never" break; } return currentVaultTimeout; diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 9d96d7c09b1..85129aaedf4 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -259,7 +260,18 @@ describe("keyService", () => { }); }); - it("clears the Auto key if vault timeout is set to anything other than null", async () => { + it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => { + await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId); + platformUtilService.getClientType.mockReturnValue(ClientType.Cli); + + await keyService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, { + userId: mockUserId, + }); + }); + + it("clears the Auto key if vault timeout is set to 10 minutes", async () => { await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId); await keyService.setUserKey(mockUserKey, mockUserId); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 4c749e9f6c4..d0b68229ea9 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -14,6 +14,7 @@ import { switchMap, } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key"; import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response"; @@ -671,9 +672,13 @@ export class DefaultKeyService implements KeyServiceAbstraction { } protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) { - let shouldStoreKey = false; switch (keySuffix) { case KeySuffixOptions.Auto: { + // Cli has fixed Never vault timeout, and it should not be affected by a policy. + if (this.platformUtilService.getClientType() == ClientType.Cli) { + return true; + } + // TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between // the VaultTimeoutSettingsSvc and this service. // This should be fixed as part of the PM-7082 - Auto Key Service work. @@ -683,11 +688,14 @@ export class DefaultKeyService implements KeyServiceAbstraction { .pipe(filter((timeout) => timeout != null)), ); - shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never; - break; + this.logService.debug( + `[KeyService] Should store auto key for vault timeout ${vaultTimeout}`, + ); + + return vaultTimeout == VaultTimeoutStringType.Never; } } - return shouldStoreKey; + return false; } protected async getKeyFromStorage( From cf2427848e8f396d3fd4491b87cb5148c7dd17f9 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Fri, 23 Jan 2026 13:36:54 -0600 Subject: [PATCH 3/3] [PM-30879] Huntress Integration (#18505) --- .../integrations/logo-huntress-siem.svg | 1 + apps/web/src/locales/en/messages.json | 9 + .../models/configuration/hec-configuration.ts | 10 +- .../models/integration-builder.spec.ts | 338 ++++++++++++++++++ .../models/integration-builder.ts | 15 +- .../configuration-template/hec-template.ts | 48 ++- .../organization-integration-service-type.ts | 1 + .../integration-card.component.spec.ts | 24 +- .../integration-card.component.ts | 328 ++++++++--------- .../connect-dialog-datadog.component.html | 2 +- .../connect-dialog-datadog.component.spec.ts | 5 +- .../connect-dialog-datadog.component.ts | 21 +- .../connect-dialog-hec.component.html | 2 +- .../connect-dialog-hec.component.spec.ts | 5 +- .../connect-dialog-hec.component.ts | 21 +- .../connect-dialog-huntress.component.html | 57 +++ .../connect-dialog-huntress.component.spec.ts | 206 +++++++++++ .../connect-dialog-huntress.component.ts | 114 ++++++ .../integration-dialog/index.ts | 2 + .../integration-dialog-result-status.ts | 11 + .../integrations.component.ts | 23 ++ libs/common/src/enums/feature-flag.enum.ts | 2 + 22 files changed, 1023 insertions(+), 222 deletions(-) create mode 100644 apps/web/src/images/integrations/logo-huntress-siem.svg create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts diff --git a/apps/web/src/images/integrations/logo-huntress-siem.svg b/apps/web/src/images/integrations/logo-huntress-siem.svg new file mode 100644 index 00000000000..06f2a3443c0 --- /dev/null +++ b/apps/web/src/images/integrations/logo-huntress-siem.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index db7332f4c2b..b15d60bf6b5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts index 275ff82e9bd..2f3a2634129 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts @@ -3,15 +3,21 @@ import { OrganizationIntegrationServiceName } from "../organization-integration- export class HecConfiguration implements OrgIntegrationConfiguration { uri: string; - scheme = "Bearer"; + scheme: string; token: string; service?: string; bw_serviceName: OrganizationIntegrationServiceName; - constructor(uri: string, token: string, bw_serviceName: OrganizationIntegrationServiceName) { + constructor( + uri: string, + token: string, + bw_serviceName: OrganizationIntegrationServiceName, + scheme: string = "Bearer", + ) { this.uri = uri; this.token = token; this.bw_serviceName = bw_serviceName; + this.scheme = scheme; } toString(): string { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts new file mode 100644 index 00000000000..6d7fad66f0e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts @@ -0,0 +1,338 @@ +import { DatadogConfiguration } from "./configuration/datadog-configuration"; +import { HecConfiguration } from "./configuration/hec-configuration"; +import { OrgIntegrationBuilder } from "./integration-builder"; +import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template"; +import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template"; +import { OrganizationIntegrationServiceName } from "./organization-integration-service-type"; +import { OrganizationIntegrationType } from "./organization-integration-type"; + +describe("OrgIntegrationBuilder", () => { + describe("buildHecConfiguration", () => { + const testUri = "https://hec.example.com:8088/services/collector"; + const testToken = "test-token"; + + it("should create HecConfiguration with correct values", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.Huntress, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBe(testUri); + expect((config as HecConfiguration).token).toBe(testToken); + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should use default Bearer scheme", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.Huntress, + ); + + expect((config as HecConfiguration).scheme).toBe("Bearer"); + }); + + it("should use custom scheme when provided", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.CrowdStrike, + "Splunk", + ); + + expect((config as HecConfiguration).scheme).toBe("Splunk"); + }); + + it("should work with CrowdStrike service name", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.CrowdStrike, + ); + + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.CrowdStrike); + }); + }); + + describe("buildHecTemplate", () => { + it("should create HecTemplate with correct values", () => { + const template = OrgIntegrationBuilder.buildHecTemplate( + "main", + OrganizationIntegrationServiceName.Huntress, + ); + + expect(template).toBeInstanceOf(HecTemplate); + expect((template as HecTemplate).index).toBe("main"); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should handle empty index", () => { + const template = OrgIntegrationBuilder.buildHecTemplate( + "", + OrganizationIntegrationServiceName.Huntress, + ); + + expect((template as HecTemplate).index).toBe(""); + }); + }); + + describe("buildDataDogConfiguration", () => { + const testUri = "https://http-intake.logs.datadoghq.com/api/v2/logs"; + const testApiKey = "test-api-key"; + + it("should create DatadogConfiguration with correct values", () => { + const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey); + + expect(config).toBeInstanceOf(DatadogConfiguration); + expect((config as DatadogConfiguration).uri).toBe(testUri); + expect((config as DatadogConfiguration).apiKey).toBe(testApiKey); + }); + + it("should always use Datadog service name", () => { + const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey); + + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("buildDataDogTemplate", () => { + it("should create DatadogTemplate with correct service name", () => { + const template = OrgIntegrationBuilder.buildDataDogTemplate( + OrganizationIntegrationServiceName.Datadog, + ); + + expect(template).toBeInstanceOf(DatadogTemplate); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("buildConfiguration", () => { + describe("HEC type", () => { + it("should build HecConfiguration from JSON string", () => { + const json = JSON.stringify({ + Uri: "https://hec.example.com", + Token: "test-token", + Scheme: "Bearer", + bw_serviceName: OrganizationIntegrationServiceName.Huntress, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBe("https://hec.example.com"); + expect((config as HecConfiguration).token).toBe("test-token"); + expect((config as HecConfiguration).scheme).toBe("Bearer"); + }); + + it("should normalize PascalCase properties to camelCase", () => { + const json = JSON.stringify({ + Uri: "https://hec.example.com", + Token: "test-token", + Scheme: "Splunk", + bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect((config as HecConfiguration).uri).toBe("https://hec.example.com"); + expect((config as HecConfiguration).token).toBe("test-token"); + expect((config as HecConfiguration).scheme).toBe("Splunk"); + }); + }); + + describe("Datadog type", () => { + it("should build DatadogConfiguration from JSON string", () => { + const json = JSON.stringify({ + Uri: "https://datadoghq.com/api", + ApiKey: "dd-api-key", + bw_serviceName: OrganizationIntegrationServiceName.Datadog, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(config).toBeInstanceOf(DatadogConfiguration); + expect((config as DatadogConfiguration).uri).toBe("https://datadoghq.com/api"); + expect((config as DatadogConfiguration).apiKey).toBe("dd-api-key"); + }); + }); + + describe("error handling", () => { + it("should throw for unsupported integration type", () => { + const json = JSON.stringify({ uri: "test" }); + + expect(() => + OrgIntegrationBuilder.buildConfiguration(999 as OrganizationIntegrationType, json), + ).toThrow("Unsupported integration type: 999"); + }); + + it("should throw for invalid JSON", () => { + expect(() => + OrgIntegrationBuilder.buildConfiguration(OrganizationIntegrationType.Hec, "invalid-json"), + ).toThrow("Invalid integration configuration: JSON parse error"); + }); + + it("should handle empty JSON string by using empty object", () => { + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + "", + ); + + expect(config).toBeInstanceOf(HecConfiguration); + }); + + it("should handle undefined values in JSON", () => { + const json = JSON.stringify({}); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBeUndefined(); + }); + }); + }); + + describe("buildTemplate", () => { + describe("HEC type", () => { + it("should build HecTemplate from JSON string", () => { + const json = JSON.stringify({ + index: "main", + bw_serviceName: OrganizationIntegrationServiceName.Huntress, + }); + + const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json); + + expect(template).toBeInstanceOf(HecTemplate); + expect((template as HecTemplate).index).toBe("main"); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should normalize PascalCase properties", () => { + const json = JSON.stringify({ + Index: "security", + bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike, + }); + + const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json); + + expect((template as HecTemplate).index).toBe("security"); + }); + }); + + describe("Datadog type", () => { + it("should build DatadogTemplate from JSON string", () => { + const json = JSON.stringify({ + bw_serviceName: OrganizationIntegrationServiceName.Datadog, + }); + + const template = OrgIntegrationBuilder.buildTemplate( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(template).toBeInstanceOf(DatadogTemplate); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("error handling", () => { + it("should throw for unsupported integration type", () => { + const json = JSON.stringify({ index: "test" }); + + expect(() => + OrgIntegrationBuilder.buildTemplate(999 as OrganizationIntegrationType, json), + ).toThrow("Unsupported integration type: 999"); + }); + + it("should throw for invalid JSON", () => { + expect(() => + OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, "invalid-json"), + ).toThrow("Invalid integration configuration: JSON parse error"); + }); + }); + }); + + describe("property case normalization", () => { + it("should convert first character to lowercase", () => { + const json = JSON.stringify({ + Uri: "https://example.com", + Token: "token", + Scheme: "Bearer", + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + // Verify the properties were normalized (accessed via camelCase) + expect((config as HecConfiguration).uri).toBe("https://example.com"); + expect((config as HecConfiguration).token).toBe("token"); + }); + + it("should handle nested objects", () => { + // Using Datadog type which has nested enrichment_details + const json = JSON.stringify({ + Uri: "https://datadoghq.com", + ApiKey: "key", + NestedObject: { + InnerProperty: "value", + }, + }); + + // This tests that nested properties are also normalized + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(config).toBeInstanceOf(DatadogConfiguration); + }); + + it("should handle arrays", () => { + const json = JSON.stringify({ + Uri: "https://example.com", + Token: "token", + Items: [{ Name: "item1" }, { Name: "item2" }], + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + }); + + it("should preserve properties that start with lowercase", () => { + const json = JSON.stringify({ + uri: "https://example.com", + token: "token", + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect((config as HecConfiguration).uri).toBe("https://example.com"); + expect((config as HecConfiguration).token).toBe("token"); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts index db682d58db4..e95f1f0ddf6 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts @@ -21,6 +21,11 @@ export interface OrgIntegrationTemplate { toString(): string; } +export const Schemas = { + Bearer: "Bearer", + Splunk: "Splunk", +} as const; + /** * Builder class for creating organization integration configurations and templates */ @@ -29,8 +34,9 @@ export class OrgIntegrationBuilder { uri: string, token: string, bw_serviceName: OrganizationIntegrationServiceName, + scheme: string = Schemas.Bearer, ): OrgIntegrationConfiguration { - return new HecConfiguration(uri, token, bw_serviceName); + return new HecConfiguration(uri, token, bw_serviceName, scheme); } static buildHecTemplate( @@ -57,7 +63,12 @@ export class OrgIntegrationBuilder { switch (type) { case OrganizationIntegrationType.Hec: { const hecConfig = this.convertToJson(configuration); - return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.bw_serviceName); + return this.buildHecConfiguration( + hecConfig.uri, + hecConfig.token, + hecConfig.bw_serviceName, + hecConfig.scheme, + ); } case OrganizationIntegrationType.Datadog: { const datadogConfig = this.convertToJson(configuration); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts index 27d71f29e59..3c0cf3b9b35 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts @@ -2,8 +2,6 @@ import { OrgIntegrationTemplate } from "../../integration-builder"; import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type"; export class HecTemplate implements OrgIntegrationTemplate { - event = "#EventMessage#"; - source = "Bitwarden"; index: string; bw_serviceName: OrganizationIntegrationServiceName; @@ -12,12 +10,46 @@ export class HecTemplate implements OrgIntegrationTemplate { this.bw_serviceName = service; } - toString(): string { - return JSON.stringify({ - Event: this.event, - Source: this.source, - Index: this.index, + private toJSON() { + const template: Record = { bw_serviceName: this.bw_serviceName, - }); + source: "bitwarden", + service: "event-logs", + event: { + object: "event", + type: "#Type#", + itemId: "#CipherId#", + collectionId: "#CollectionId#", + groupId: "#GroupId#", + policyId: "#PolicyId#", + memberId: "#UserId#", + actingUserId: "#ActingUserId#", + installationId: "#InstallationId#", + date: "#DateIso8601#", + device: "#DeviceType#", + ipAddress: "#IpAddress#", + secretId: "#SecretId#", + projectId: "#ProjectId#", + serviceAccountId: "#ServiceAccountId#", + actingUserName: "#ActingUserName#", + actingUserEmail: "#ActingUserEmail#", + actingUserType: "#ActingUserType#", + userName: "#UserName#", + userEmail: "#UserEmail#", + userType: "#UserType#", + groupName: "#GroupName#", + }, + }; + + // Only include index if it's provided + if (this.index && this.index.trim() !== "") { + template.index = this.index; + } + + return template; + } + + toString(): string { + return JSON.stringify(this.toJSON()); } } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts index 9634ad7249a..5c4b851e7b1 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts @@ -1,6 +1,7 @@ export const OrganizationIntegrationServiceName = Object.freeze({ CrowdStrike: "CrowdStrike", Datadog: "Datadog", + Huntress: "Huntress", } as const); export type OrganizationIntegrationServiceName = diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts index 37bd504643c..928bb9488b3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts @@ -16,13 +16,15 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { HecConnectDialogResultStatus, openHecConnectDialog } from "../integration-dialog"; +import { IntegrationDialogResultStatus, openHecConnectDialog } from "../integration-dialog"; import { IntegrationCardComponent } from "./integration-card.component"; jest.mock("../integration-dialog", () => ({ openHecConnectDialog: jest.fn(), - HecConnectDialogResultStatus: { Edited: "edit", Delete: "delete" }, + openDatadogConnectDialog: jest.fn(), + openHuntressConnectDialog: jest.fn(), + IntegrationDialogResultStatus: { Edited: "edit", Delete: "delete" }, })); describe("IntegrationCardComponent", () => { @@ -276,7 +278,7 @@ describe("IntegrationCardComponent", () => { it("should call updateHec if isUpdateAvailable is true", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -317,7 +319,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -354,7 +356,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -382,7 +384,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -404,7 +406,7 @@ describe("IntegrationCardComponent", () => { it("should show toast on error while saving", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -427,7 +429,7 @@ describe("IntegrationCardComponent", () => { it("should show mustBeOwner toast on error while inserting data", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -450,7 +452,7 @@ describe("IntegrationCardComponent", () => { it("should show mustBeOwner toast on error while updating data", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -472,7 +474,7 @@ describe("IntegrationCardComponent", () => { it("should show toast on error while deleting", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -495,7 +497,7 @@ describe("IntegrationCardComponent", () => { it("should show mustbeOwner toast on 404 while deleting", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index 8026e14c2fc..f423a9b86d9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -12,7 +12,12 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; -import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder"; +import { + OrgIntegrationBuilder, + OrgIntegrationConfiguration, + OrgIntegrationTemplate, + Schemas, +} from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder"; import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; @@ -23,7 +28,6 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { BaseCardComponent, CardContentComponent, - DialogRef, DialogService, ToastService, } from "@bitwarden/components"; @@ -32,10 +36,11 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { HecConnectDialogResult, DatadogConnectDialogResult, - HecConnectDialogResultStatus, - DatadogConnectDialogResultStatus, + HuntressConnectDialogResult, + IntegrationDialogResultStatus, openDatadogConnectDialog, openHecConnectDialog, + openHuntressConnectDialog, } from "../integration-dialog/index"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -164,14 +169,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async setupConnection() { - let dialog: DialogRef; - if (this.integrationSettings?.integrationType === null) { return; } if (this.integrationSettings?.integrationType === OrganizationIntegrationType.Datadog) { - dialog = openDatadogConnectDialog(this.dialogService, { + const dialog = openDatadogConnectDialog(this.dialogService, { data: { settings: this.integrationSettings, }, @@ -179,37 +182,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const result = await lastValueFrom(dialog.closed); - // the dialog was cancelled - if (!result || !result.success) { - return; - } + await this.handleIntegrationDialogResult( + result, + () => this.deleteDatadog(), + (res) => this.saveDatadog(res), + ); + } else if (this.integrationSettings.name === OrganizationIntegrationServiceName.Huntress) { + // Huntress uses HEC protocol but has its own dialog + const dialog = openHuntressConnectDialog(this.dialogService, { + data: { + settings: this.integrationSettings, + }, + }); - try { - if (result.success === HecConnectDialogResultStatus.Delete) { - await this.deleteDatadog(); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToDeleteIntegration"), - }); - } + const result = await lastValueFrom(dialog.closed); - try { - if (result.success === DatadogConnectDialogResultStatus.Edited) { - await this.saveDatadog(result as DatadogConnectDialogResult); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToSaveIntegration"), - }); - } + await this.handleIntegrationDialogResult( + result, + () => this.deleteHuntress(), + (res) => this.saveHuntress(res), + ); } else { // invoke the dialog to connect the integration - dialog = openHecConnectDialog(this.dialogService, { + const dialog = openHecConnectDialog(this.dialogService, { data: { settings: this.integrationSettings, }, @@ -217,15 +212,113 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const result = await lastValueFrom(dialog.closed); - // the dialog was cancelled - if (!result || !result.success) { - return; + await this.handleIntegrationDialogResult( + result, + () => this.deleteHec(), + (res) => this.saveHec(res), + ); + } + } + + /** + * Generic save method + */ + private async saveIntegration( + integrationType: OrganizationIntegrationType, + config: OrgIntegrationConfiguration, + template: OrgIntegrationTemplate, + ): Promise { + let response = { mustBeOwner: false, success: false }; + + if (this.isUpdateAvailable) { + // retrieve org integration and configuration ids + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); } + // update existing integration and configuration + response = await this.organizationIntegrationService.update( + this.organizationId, + orgIntegrationId, + integrationType, + orgIntegrationConfigurationId, + config, + template, + ); + } else { + // create new integration and configuration + response = await this.organizationIntegrationService.save( + this.organizationId, + integrationType, + config, + template, + ); + } + + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + + /** + * Generic delete method + */ + private async deleteIntegration(): Promise { + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); + } + + const response = await this.organizationIntegrationService.delete( + this.organizationId, + orgIntegrationId, + orgIntegrationConfigurationId, + ); + + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + + /** + * Generic dialog result handler + * Handles both delete and edit actions with proper error handling + */ + private async handleIntegrationDialogResult( + result: T | undefined, + deleteCallback: () => Promise, + saveCallback: (result: T) => Promise, + ): Promise { + // User cancelled the dialog or closed it without saving + if (!result || !result.success) { + return; + } + + // Handle delete action + if (result.success === IntegrationDialogResultStatus.Delete) { try { - if (result.success === HecConnectDialogResultStatus.Delete) { - await this.deleteHec(); - } + await deleteCallback(); } catch { this.toastService.showToast({ variant: "error", @@ -233,11 +326,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { message: this.i18nService.t("failedToDeleteIntegration"), }); } + return; + } + // Handle edit/save action + if (result.success === IntegrationDialogResultStatus.Edited) { try { - if (result.success === HecConnectDialogResultStatus.Edited) { - await this.saveHec(result as HecConnectDialogResult); - } + await saveCallback(result); } catch { this.toastService.showToast({ variant: "error", @@ -249,8 +344,6 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async saveHec(result: HecConnectDialogResult) { - let response = { mustBeOwner: false, success: false }; - const config = OrgIntegrationBuilder.buildHecConfiguration( result.url, result.bearerToken, @@ -261,148 +354,45 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { this.integrationSettings.name as OrganizationIntegrationServiceName, ); - if (this.isUpdateAvailable) { - // retrieve org integration and configuration ids - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - // update existing integration and configuration - response = await this.organizationIntegrationService.update( - this.organizationId, - orgIntegrationId, - OrganizationIntegrationType.Hec, - orgIntegrationConfigurationId, - config, - template, - ); - } else { - // create new integration and configuration - response = await this.organizationIntegrationService.save( - this.organizationId, - OrganizationIntegrationType.Hec, - config, - template, - ); - } - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.saveIntegration(OrganizationIntegrationType.Hec, config, template); } async deleteHec() { - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + await this.deleteIntegration(); + } - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - const response = await this.organizationIntegrationService.delete( - this.organizationId, - orgIntegrationId, - orgIntegrationConfigurationId, + async saveHuntress(result: HuntressConnectDialogResult) { + // Huntress uses "Splunk" scheme for HEC protocol compatibility + const config = OrgIntegrationBuilder.buildHecConfiguration( + result.url, + result.token, + OrganizationIntegrationServiceName.Huntress, + Schemas.Splunk, + ); + // Huntress SIEM doesn't require the index field + const template = OrgIntegrationBuilder.buildHecTemplate( + "", + OrganizationIntegrationServiceName.Huntress, ); - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } + await this.saveIntegration(OrganizationIntegrationType.Hec, config, template); + } - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + async deleteHuntress() { + await this.deleteIntegration(); } async saveDatadog(result: DatadogConnectDialogResult) { - let response = { mustBeOwner: false, success: false }; - const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey); const template = OrgIntegrationBuilder.buildDataDogTemplate( this.integrationSettings.name as OrganizationIntegrationServiceName, ); - if (this.isUpdateAvailable) { - // retrieve org integration and configuration ids - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - // update existing integration and configuration - response = await this.organizationIntegrationService.update( - this.organizationId, - orgIntegrationId, - OrganizationIntegrationType.Datadog, - orgIntegrationConfigurationId, - config, - template, - ); - } else { - // create new integration and configuration - response = await this.organizationIntegrationService.save( - this.organizationId, - OrganizationIntegrationType.Datadog, - config, - template, - ); - } - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.saveIntegration(OrganizationIntegrationType.Datadog, config, template); } async deleteDatadog() { - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - const response = await this.organizationIntegrationService.delete( - this.organizationId, - orgIntegrationId, - orgIntegrationConfigurationId, - ); - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.deleteIntegration(); } private showMustBeOwnerToast() { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html index ddc108201b0..523cbc66d56 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html @@ -23,7 +23,7 @@ {{ "apiKey" | i18n }} - + {{ "apiKey" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts index 7298087e7e4..76fc8144309 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts @@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/ import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + import { ConnectDatadogDialogComponent, DatadogConnectDialogParams, DatadogConnectDialogResult, - DatadogConnectDialogResultStatus, openDatadogConnectDialog, } from "./connect-dialog-datadog.component"; @@ -149,7 +150,7 @@ describe("ConnectDialogDatadogComponent", () => { url: "https://test.com", apiKey: "token", service: "Test Service", - success: DatadogConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts index 47760c6311a..cedc8e5d3e3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts @@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + export type DatadogConnectDialogParams = { settings: Integration; }; @@ -16,17 +21,9 @@ export interface DatadogConnectDialogResult { url: string; apiKey: string; service: string; - success: DatadogConnectDialogResultStatusType | null; + success: IntegrationDialogResultStatusType | null; } -export const DatadogConnectDialogResultStatus = { - Edited: "edit", - Delete: "delete", -} as const; - -export type DatadogConnectDialogResultStatusType = - (typeof DatadogConnectDialogResultStatus)[keyof typeof DatadogConnectDialogResultStatus]; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -78,7 +75,7 @@ export class ConnectDatadogDialogComponent implements OnInit { this.formGroup.markAllAsTouched(); return; } - const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Edited); + const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Edited); this.dialogRef.close(result); @@ -95,13 +92,13 @@ export class ConnectDatadogDialogComponent implements OnInit { }); if (confirmed) { - const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Delete); + const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Delete); this.dialogRef.close(result); } }; private getDatadogConnectDialogResult( - status: DatadogConnectDialogResultStatusType, + status: IntegrationDialogResultStatusType, ): DatadogConnectDialogResult { const formJson = this.formGroup.getRawValue(); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html index 0dad1621440..1cafd7c3211 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html @@ -23,7 +23,7 @@ {{ "bearerToken" | i18n }} - + {{ "apiKey" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts index 9f640ebbcc7..c337f2872d6 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts @@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/ import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + import { ConnectHecDialogComponent, HecConnectDialogParams, HecConnectDialogResult, - HecConnectDialogResultStatus, openHecConnectDialog, } from "./connect-dialog-hec.component"; @@ -155,7 +156,7 @@ describe("ConnectDialogHecComponent", () => { bearerToken: "token", index: "1", service: "Test Service", - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts index 3612f2c76cb..3d38cfd1f79 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts @@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + export type HecConnectDialogParams = { settings: Integration; }; @@ -17,17 +22,9 @@ export interface HecConnectDialogResult { bearerToken: string; index: string; service: string; - success: HecConnectDialogResultStatusType | null; + success: IntegrationDialogResultStatusType | null; } -export const HecConnectDialogResultStatus = { - Edited: "edit", - Delete: "delete", -} as const; - -export type HecConnectDialogResultStatusType = - (typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus]; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -81,7 +78,7 @@ export class ConnectHecDialogComponent implements OnInit { this.formGroup.markAllAsTouched(); return; } - const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Edited); + const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Edited); this.dialogRef.close(result); @@ -98,13 +95,13 @@ export class ConnectHecDialogComponent implements OnInit { }); if (confirmed) { - const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Delete); + const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Delete); this.dialogRef.close(result); } }; private getHecConnectDialogResult( - status: HecConnectDialogResultStatusType, + status: IntegrationDialogResultStatusType, ): HecConnectDialogResult { const formJson = this.formGroup.getRawValue(); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html new file mode 100644 index 00000000000..7c2894ff8b1 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html @@ -0,0 +1,57 @@ +
+ + + {{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }} + +
+ @if (loading) { + + + + } + @if (!loading) { + + + {{ "httpEventCollectorUrl" | i18n }} + + + + + {{ "httpEventCollectorToken" | i18n }} + + + + } +
+ + + + + @if (canDelete) { +
+ +
+ } +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts new file mode 100644 index 00000000000..9c5dc58a762 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts @@ -0,0 +1,206 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { IntegrationType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + +import { + ConnectHuntressDialogComponent, + HuntressConnectDialogParams, + HuntressConnectDialogResult, + openHuntressConnectDialog, +} from "./connect-dialog-huntress.component"; + +beforeAll(() => { + // Mock element.animate for jsdom + // the animate function is not available in jsdom, so we provide a mock implementation + // This is necessary for tests that rely on animations + // This mock does not perform any actual animations, it just provides a structure that allows tests + // to run without throwing errors related to missing animate function + if (!HTMLElement.prototype.animate) { + HTMLElement.prototype.animate = function () { + return { + play: () => {}, + pause: () => {}, + finish: () => {}, + cancel: () => {}, + reverse: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + onfinish: null, + oncancel: null, + startTime: 0, + currentTime: 0, + playbackRate: 1, + playState: "idle", + replaceState: "active", + effect: null, + finished: Promise.resolve(), + id: "", + remove: () => {}, + timeline: null, + ready: Promise.resolve(), + } as unknown as Animation; + }; + } +}); + +describe("ConnectHuntressDialogComponent", () => { + let component: ConnectHuntressDialogComponent; + let fixture: ComponentFixture; + let dialogRefMock = mock>(); + const mockI18nService = mock(); + + const integrationMock: Integration = { + name: "Huntress", + image: "test-image.png", + linkURL: "https://example.com", + imageDarkMode: "test-image-dark.png", + newBadgeExpiration: "2024-12-31", + description: "Test Description", + canSetupConnection: true, + type: IntegrationType.EVENT, + } as Integration; + + const connectInfo: HuntressConnectDialogParams = { + settings: integrationMock, + }; + + beforeEach(async () => { + dialogRefMock = mock>(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: connectInfo }, + { provide: DialogRef, useValue: dialogRefMock }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectHuntressDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + mockI18nService.t.mockImplementation((key) => key); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize form with empty values and service name", () => { + expect(component.formGroup.value).toEqual({ + url: "", + token: "", + service: "Huntress", + }); + }); + + it("should have required validators for url and token fields", () => { + component.formGroup.setValue({ url: "", token: "", service: "" }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should require url to be at least 7 characters long", () => { + component.formGroup.setValue({ + url: "test", + token: "token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://hec.huntress.io", + token: "token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should call dialogRef.close with correct result on submit", async () => { + component.formGroup.setValue({ + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + }); + + await component.submit(); + + expect(dialogRefMock.close).toHaveBeenCalledWith({ + integrationSettings: integrationMock, + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + success: IntegrationDialogResultStatus.Edited, + }); + }); + + it("should not submit when form is invalid", async () => { + component.formGroup.setValue({ + url: "", + token: "", + service: "Huntress", + }); + + await component.submit(); + + expect(dialogRefMock.close).not.toHaveBeenCalled(); + expect(component.formGroup.touched).toBeTruthy(); + }); + + it("should return false for isUpdateAvailable when no config exists", () => { + component.huntressConfig = null; + expect(component.isUpdateAvailable).toBeFalsy(); + }); + + it("should return true for isUpdateAvailable when config exists", () => { + component.huntressConfig = { uri: "test", token: "test" } as any; + expect(component.isUpdateAvailable).toBeTruthy(); + }); + + it("should return false for canDelete when no config exists", () => { + component.huntressConfig = null; + expect(component.canDelete).toBeFalsy(); + }); + + it("should return true for canDelete when config exists", () => { + component.huntressConfig = { uri: "test", token: "test" } as any; + expect(component.canDelete).toBeTruthy(); + }); +}); + +describe("openHuntressConnectDialog", () => { + it("should call dialogService.open with correct params", () => { + const dialogServiceMock = mock(); + const config: DialogConfig< + HuntressConnectDialogParams, + DialogRef + > = { + data: { settings: { name: "Huntress" } as Integration }, + } as any; + + openHuntressConnectDialog(dialogServiceMock, config); + + expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHuntressDialogComponent, config); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts new file mode 100644 index 00000000000..953a8cdb0ac --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts @@ -0,0 +1,114 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { HecConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/hec-configuration"; +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + +export type HuntressConnectDialogParams = { + settings: Integration; +}; + +export interface HuntressConnectDialogResult { + integrationSettings: Integration; + url: string; + token: string; + service: string; + success: IntegrationDialogResultStatusType | null; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./connect-dialog-huntress.component.html", + imports: [SharedModule], +}) +export class ConnectHuntressDialogComponent implements OnInit { + loading = false; + huntressConfig: HecConfiguration | null = null; + formGroup = this.formBuilder.group({ + url: ["", [Validators.required, Validators.minLength(7)]], + token: ["", Validators.required], + service: [""], // Programmatically set in ngOnInit, not shown to user + }); + + constructor( + @Inject(DIALOG_DATA) protected connectInfo: HuntressConnectDialogParams, + protected formBuilder: FormBuilder, + private dialogRef: DialogRef, + private dialogService: DialogService, + ) {} + + ngOnInit(): void { + this.huntressConfig = + this.connectInfo.settings.organizationIntegration?.getConfiguration() ?? + null; + + this.formGroup.patchValue({ + url: this.huntressConfig?.uri || "", + token: this.huntressConfig?.token || "", + service: this.connectInfo.settings.name, + }); + } + + get isUpdateAvailable(): boolean { + return !!this.huntressConfig; + } + + get canDelete(): boolean { + return !!this.huntressConfig; + } + + submit = async (): Promise => { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Edited); + + this.dialogRef.close(result); + + return; + }; + + delete = async (): Promise => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: "deleteItemConfirmation", + }, + type: "warning", + }); + + if (confirmed) { + const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Delete); + this.dialogRef.close(result); + } + }; + + private getHuntressConnectDialogResult( + status: IntegrationDialogResultStatusType, + ): HuntressConnectDialogResult { + const formJson = this.formGroup.getRawValue(); + + return { + integrationSettings: this.connectInfo.settings, + url: formJson.url || "", + token: formJson.token || "", + service: formJson.service || "", + success: status, + }; + } +} + +export function openHuntressConnectDialog( + dialogService: DialogService, + config: DialogConfig>, +) { + return dialogService.open(ConnectHuntressDialogComponent, config); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts index 9852f3fe5c8..a41ee826cbc 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts @@ -1,2 +1,4 @@ export * from "./connect-dialog/connect-dialog-hec.component"; export * from "./connect-dialog/connect-dialog-datadog.component"; +export * from "./connect-dialog/connect-dialog-huntress.component"; +export * from "./integration-dialog-result-status"; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts new file mode 100644 index 00000000000..1774088c203 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts @@ -0,0 +1,11 @@ +/** + * Shared status types for integration dialog results + * Used across all SIEM integration dialogs (HEC, Datadog, Huntress, etc.) + */ +export const IntegrationDialogResultStatus = { + Edited: "edit", + Delete: "delete", +} as const; + +export type IntegrationDialogResultStatusType = + (typeof IntegrationDialogResultStatus)[keyof typeof IntegrationDialogResultStatus]; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 6517182b21e..5485410f735 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -32,6 +32,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { tabIndex: number = 0; organization$: Observable = new Observable(); isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false; + isEventManagementForHuntressEnabled: boolean = false; private destroy$ = new Subject(); // initialize the integrations list with default integrations @@ -258,6 +259,13 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled; }); + this.configService + .getFeatureFlag$(FeatureFlag.EventManagementForHuntress) + .pipe(takeUntil(this.destroy$)) + .subscribe((isEnabled) => { + this.isEventManagementForHuntressEnabled = isEnabled; + }); + // Add the new event based items to the list if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) { const crowdstrikeIntegration: Integration = { @@ -285,6 +293,21 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.integrationsList.push(datadogIntegration); } + // Add Huntress SIEM integration (separate feature flag) + if (this.isEventManagementForHuntressEnabled) { + const huntressIntegration: Integration = { + name: OrganizationIntegrationServiceName.Huntress, + linkURL: "https://bitwarden.com/help/huntress-siem/", + image: "../../../../../../../images/integrations/logo-huntress-siem.svg", + type: IntegrationType.EVENT, + description: "huntressEventIntegrationDesc", + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Hec, + }; + + this.integrationsList.push(huntressIntegration); + } + // For all existing event based configurations loop through and assign the // organizationIntegration for the correct services. this.organizationIntegrationService.integrations$ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index a6b0de1e2e5..c96f6996078 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -56,6 +56,7 @@ export enum FeatureFlag { /* DIRT */ EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", + EventManagementForHuntress = "event-management-for-huntress", PhishingDetection = "phishing-detection", /* Vault */ @@ -119,6 +120,7 @@ export const DefaultFeatureFlagValue = { /* DIRT */ [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, + [FeatureFlag.EventManagementForHuntress]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, /* Vault */