diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.spec.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.spec.ts new file mode 100644 index 00000000000..ae4b48ac079 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.spec.ts @@ -0,0 +1,228 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute, convertToParamMap } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EventCollectionService, EventType } from "@bitwarden/common/dirt/event-logs"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { MessageSender } from "@bitwarden/messaging"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import { + PHISHING_DETECTION_CANCEL_COMMAND, + PHISHING_DETECTION_CONTINUE_COMMAND, +} from "../services/phishing-detection.service"; + +import { PhishingWarning } from "./phishing-warning.component"; + +describe("PhishingWarning", () => { + const mockUserId = "test-user-id" as UserId; + const mockPhishingUrl = "https://phishing.example.com"; + + let fixture: ComponentFixture; + let component: PhishingWarning; + let accountService: FakeAccountService; + let organizationService: ReturnType>; + let eventCollectionService: ReturnType>; + let messageSender: ReturnType>; + + const orgWithEvents = { id: "org-1", useEvents: true, usePhishingBlocker: true } as Organization; + const orgWithoutEvents = { + id: "org-2", + useEvents: false, + usePhishingBlocker: false, + } as Organization; + + beforeEach(async () => { + accountService = mockAccountServiceWith(mockUserId); + organizationService = mock(); + eventCollectionService = mock(); + messageSender = mock(); + + organizationService.organizations$.mockImplementation(() => + of([orgWithEvents, orgWithoutEvents]), + ); + eventCollectionService.collect.mockResolvedValue(undefined); + + jest.spyOn(BrowserApi, "getCurrentTab").mockResolvedValue({ id: 42 } as chrome.tabs.Tab); + + await TestBed.configureTestingModule({ + imports: [PhishingWarning], + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParamMap: of(convertToParamMap({ phishingUrl: mockPhishingUrl })), + }, + }, + { provide: MessageSender, useValue: messageSender }, + { provide: EventCollectionService, useValue: eventCollectionService }, + { provide: OrganizationService, useValue: organizationService }, + { provide: AccountService, useValue: accountService }, + { provide: I18nService, useValue: { t: jest.fn((key: string) => key) } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PhishingWarning); + component = fixture.componentInstance; + }); + + describe("ngOnInit", () => { + it("collects PhishingBlocker_SiteAccessed for each org with useEvents", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(eventCollectionService.collect).toHaveBeenCalledWith( + EventType.PhishingBlocker_SiteAccessed, + undefined, + false, + "org-1", + ); + expect(eventCollectionService.collect).not.toHaveBeenCalledWith( + EventType.PhishingBlocker_SiteAccessed, + undefined, + false, + "org-2", + ); + expect(eventCollectionService.collect).toHaveBeenCalledTimes(1); + }); + + it("does not collect events when no orgs have useEvents", async () => { + organizationService.organizations$.mockImplementation(() => of([orgWithoutEvents])); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(eventCollectionService.collect).not.toHaveBeenCalled(); + }); + }); + + describe("closeTab", () => { + it("collects PhishingBlocker_SiteExited for each org with useEvents before closing", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + eventCollectionService.collect.mockClear(); + + await component.closeTab(); + + expect(eventCollectionService.collect).toHaveBeenCalledWith( + EventType.PhishingBlocker_SiteExited, + undefined, + false, + "org-1", + ); + expect(eventCollectionService.collect).not.toHaveBeenCalledWith( + EventType.PhishingBlocker_SiteExited, + undefined, + false, + "org-2", + ); + expect(eventCollectionService.collect).toHaveBeenCalledTimes(1); + expect(messageSender.send).toHaveBeenCalledWith(PHISHING_DETECTION_CANCEL_COMMAND, { + tabId: 42, + }); + }); + }); + + describe("continueAnyway", () => { + it("collects PhishingBlocker_Bypassed for each org with useEvents", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + eventCollectionService.collect.mockClear(); + + await component.continueAnyway(); + + expect(eventCollectionService.collect).toHaveBeenCalledWith( + EventType.PhishingBlocker_Bypassed, + undefined, + true, + "org-1", + ); + expect(eventCollectionService.collect).not.toHaveBeenCalledWith( + EventType.PhishingBlocker_Bypassed, + undefined, + true, + "org-2", + ); + expect(eventCollectionService.collect).toHaveBeenCalledTimes(1); + expect(messageSender.send).toHaveBeenCalledWith(PHISHING_DETECTION_CONTINUE_COMMAND, { + tabId: 42, + url: mockPhishingUrl, + }); + }); + }); + describe("getOrgsToNotify", () => { + it("filters organizations by useEvents and usePhishingBlocker", async () => { + const orgWithBoth = { + id: "org-1", + useEvents: true, + usePhishingBlocker: true, + } as Organization; + const orgWithoutEvents = { + id: "org-2", + useEvents: false, + usePhishingBlocker: true, + } as Organization; + const orgWithoutPhishingBlocker = { + id: "org-3", + useEvents: true, + usePhishingBlocker: false, + } as Organization; + const orgWithNeither = { + id: "org-4", + useEvents: false, + usePhishingBlocker: false, + } as Organization; + + organizationService.organizations$.mockImplementation(() => + of([orgWithBoth, orgWithoutEvents, orgWithoutPhishingBlocker, orgWithNeither]), + ); + + fixture.detectChanges(); + const result = await fixture.componentInstance["getOrgsToNotify"](); + + expect(result).toEqual([orgWithBoth]); + }); + + it("returns empty array when no orgs have both useEvents and usePhishingBlocker", async () => { + const orgWithOnlyEvents = { + id: "org-1", + useEvents: true, + usePhishingBlocker: false, + } as Organization; + const orgWithOnlyPhishingBlocker = { + id: "org-2", + useEvents: false, + usePhishingBlocker: true, + } as Organization; + + organizationService.organizations$.mockImplementation(() => + of([orgWithOnlyEvents, orgWithOnlyPhishingBlocker]), + ); + + fixture.detectChanges(); + const result = await fixture.componentInstance["getOrgsToNotify"](); + + expect(result).toEqual([]); + }); + + it("returns all orgs when all have useEvents and usePhishingBlocker", async () => { + const orgs = [ + { id: "org-1", useEvents: true, usePhishingBlocker: true } as Organization, + { id: "org-2", useEvents: true, usePhishingBlocker: true } as Organization, + ]; + + organizationService.organizations$.mockImplementation(() => of(orgs)); + + fixture.detectChanges(); + const result = await fixture.componentInstance["getOrgsToNotify"](); + + expect(result).toEqual(orgs); + }); + }); +}); diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts index 419de04d9f4..e9e89bd9323 100644 --- a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts @@ -1,10 +1,14 @@ import { CommonModule } from "@angular/common"; -import { Component, inject } from "@angular/core"; +import { Component, inject, OnInit } from "@angular/core"; import { ActivatedRoute, RouterModule } from "@angular/router"; import { firstValueFrom, map } from "rxjs"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { EventCollectionService, EventType } from "@bitwarden/common/dirt/event-logs"; import { AsyncActionsModule, ButtonModule, @@ -17,6 +21,7 @@ import { TypographyModule, } from "@bitwarden/components"; import { MessageSender } from "@bitwarden/messaging"; +import { I18nPipe } from "@bitwarden/ui-common"; import { PHISHING_DETECTION_CANCEL_COMMAND, @@ -32,7 +37,6 @@ import { imports: [ CommonModule, SvgModule, - JslibModule, LinkModule, FormFieldModule, AsyncActionsModule, @@ -42,32 +46,57 @@ import { IconTileComponent, CalloutComponent, TypographyModule, + I18nPipe, ], }) // FIXME(https://bitwarden.atlassian.net/browse/PM-28231): Use Component suffix // eslint-disable-next-line @angular-eslint/component-class-suffix -export class PhishingWarning { +export class PhishingWarning implements OnInit { private activatedRoute = inject(ActivatedRoute); private messageSender = inject(MessageSender); + private eventCollectionService = inject(EventCollectionService); + private organizationService = inject(OrganizationService); + private accountService = inject(AccountService); private phishingUrl$ = this.activatedRoute.queryParamMap.pipe( map((params) => params.get("phishingUrl") || ""), ); protected phishingHostname$ = this.phishingUrl$.pipe(map((url) => new URL(url).hostname)); + async ngOnInit() { + await this.recordEvents(EventType.PhishingBlocker_SiteAccessed, false); + } + async closeTab() { + await this.recordEvents(EventType.PhishingBlocker_SiteExited, false); const tabId = await this.getTabId(); - this.messageSender.send(PHISHING_DETECTION_CANCEL_COMMAND, { - tabId, - }); + this.messageSender.send(PHISHING_DETECTION_CANCEL_COMMAND, { tabId }); } + async continueAnyway() { + await this.recordEvents(EventType.PhishingBlocker_Bypassed, true); const url = await firstValueFrom(this.phishingUrl$); const tabId = await this.getTabId(); - this.messageSender.send(PHISHING_DETECTION_CONTINUE_COMMAND, { - tabId, - url, - }); + this.messageSender.send(PHISHING_DETECTION_CONTINUE_COMMAND, { tabId, url }); + } + + private async recordEvents(eventType: EventType, uploadImmediately: boolean): Promise { + try { + const orgs = await this.getOrgsToNotify(); + + // keep this sequential, using a Promise.all causes a race condition + for (const org of orgs) { + await this.eventCollectionService.collect(eventType, undefined, uploadImmediately, org.id); + } + } catch { + // Event collection failure should not block the user action + } + } + + private async getOrgsToNotify(): Promise { + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + const orgs = await firstValueFrom(this.organizationService.organizations$(userId)); + return orgs.filter((o) => o.useEvents && o.usePhishingBlocker); } private async getTabId() { diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts index af7eec8dc8a..f3c1fac7020 100644 --- a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts @@ -3,10 +3,14 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; import { DeactivatedOrg } from "@bitwarden/assets/svg"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EventCollectionService } from "@bitwarden/common/dirt/event-logs"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components"; import { MessageSender } from "@bitwarden/messaging"; @@ -87,6 +91,24 @@ export default { }, }, mockActivatedRoute({ phishingUrl: "http://malicious-example.com" }), + { + provide: EventCollectionService, + useValue: { + collect: () => Promise.resolve(), + } as Partial, + }, + { + provide: AccountService, + useValue: { + activeAccount$: of({ id: "test-user-id" as UserId }), + } as Partial, + }, + { + provide: OrganizationService, + useValue: { + organizations$: () => of([]), + } as Partial, + }, ], }), ], diff --git a/apps/web/src/app/dirt/event-logs/services/event.service.ts b/apps/web/src/app/dirt/event-logs/services/event.service.ts index 233dc467004..6b67231f30f 100644 --- a/apps/web/src/app/dirt/event-logs/services/event.service.ts +++ b/apps/web/src/app/dirt/event-logs/services/event.service.ts @@ -698,6 +698,19 @@ export class EventService { this.formatServiceAccountId(ev, options), ); break; + case EventType.PhishingBlocker_SiteAccessed: + msg = this.i18nService.t("phishingBlockerSiteAccessed"); + humanReadableMsg = this.i18nService.t("phishingBlockerSiteAccessed"); + break; + case EventType.PhishingBlocker_SiteExited: + msg = this.i18nService.t("phishingBlockerSiteExited"); + humanReadableMsg = this.i18nService.t("phishingBlockerSiteExited"); + break; + case EventType.PhishingBlocker_Bypassed: + msg = this.i18nService.t("phishingBlockerBypassed"); + humanReadableMsg = this.i18nService.t("phishingBlockerBypassed"); + break; + default: break; } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 832c1c906b1..7ee46e21b06 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9570,6 +9570,15 @@ } } }, + "phishingBlockerSiteAccessed": { + "message": "User attempted to access known phishing site" + }, + "phishingBlockerSiteExited": { + "message": "User exited phishing warning page" + }, + "phishingBlockerBypassed": { + "message": "User bypassed phishing warning" + }, "sdk": { "message": "SDK", "description": "Software Development Kit" diff --git a/libs/common/src/dirt/event-logs/enums/event-type.enum.ts b/libs/common/src/dirt/event-logs/enums/event-type.enum.ts index c86a2bb2877..a71d741150c 100644 --- a/libs/common/src/dirt/event-logs/enums/event-type.enum.ts +++ b/libs/common/src/dirt/event-logs/enums/event-type.enum.ts @@ -124,4 +124,8 @@ export enum EventType { ServiceAccount_GroupRemoved = 2303, ServiceAccount_Created = 2304, ServiceAccount_Deleted = 2305, + + PhishingBlocker_SiteAccessed = 2400, + PhishingBlocker_SiteExited = 2401, + PhishingBlocker_Bypassed = 2402, }